feat: posts open in the rendered markdown preview by default, ctrl+e toggles to the editor

This commit is contained in:
2026-07-16 22:22:17 +02:00
parent 46fd6c1b85
commit b6b1d16e54
3 changed files with 66 additions and 30 deletions

View File

@@ -21,9 +21,10 @@ defmodule BDS.TUI do
`yyyy-mm-dd`; enter keeps the filter, esc clears it),
`:` vi-style command prompt over the Blog-menu shell commands, `?`
command help (commands whose report/apply UI is GUI-only are marked).
Editor focus: text keys edit, `ctrl+s` save, `ctrl+p` publish,
`ctrl+e` word-wrapped preview, `ctrl+t` edit title, `ctrl+l` cycle
language, `ctrl+g` AI suggestions, `esc` back to sidebar. `ctrl+q`
Posts open in the rendered markdown preview (new empty posts open in
the editor). Editor focus: `ctrl+e` toggles preview/editor, text keys
edit, `ctrl+s` save, `ctrl+p` publish, `ctrl+t` edit title, `ctrl+l`
cycle language, `ctrl+g` AI suggestions, `esc` back to sidebar. `ctrl+q`
quits, `esc` also closes a media preview. The UI locale is the
server-side UI language setting.
"""
@@ -215,7 +216,9 @@ defmodule BDS.TUI do
when is_binary(project_id) do
case Posts.create_post(%{project_id: project_id, title: "", content: ""}) do
{:ok, post} ->
{:noreply, state |> load_sidebar() |> open_post(post.id)}
# A fresh, empty post starts in the editor — there is nothing to
# preview yet; existing posts open in the preview instead.
{:noreply, state |> load_sidebar() |> open_post(post.id, false)}
{:error, reason} ->
{:noreply, toast(state, dgettext("ui", "New Post"), inspect(reason))}
@@ -691,9 +694,6 @@ defmodule BDS.TUI do
defp editor_key(%{code: "esc"}, %{editor: %{editing_title?: true}} = state),
do: {:noreply, put_in(state.editor.editing_title?, false)}
defp editor_key(%{code: "esc"}, %{editor: %{preview?: true}} = state),
do: {:noreply, put_in(state.editor.preview?, false)}
defp editor_key(%{code: "esc"}, state), do: {:noreply, %{state | focus: :sidebar}}
defp editor_key(%{code: "e", modifiers: ["ctrl"]}, state),
@@ -1470,7 +1470,9 @@ defmodule BDS.TUI do
# ── Post editor ──────────────────────────────────────────────────────────
defp open_post(state, post_id) do
# Posts open in the rendered markdown preview by default — the editor is
# one ctrl+e away. New (empty) posts pass preview?: false.
defp open_post(state, post_id, preview? \\ true) do
case Posts.get_post(post_id) do
nil ->
toast(state, dgettext("ui", "Posts"), dgettext("ui", "Post not found."))
@@ -1478,11 +1480,17 @@ defmodule BDS.TUI do
post ->
metadata = Metadata.project_metadata(post.project_id)
language = Metadata.canonical_language(post, metadata)
%{state | editor: build_editor(post, metadata, language), focus: :editor, image: nil}
%{
state
| editor: build_editor(post, metadata, language, preview?),
focus: :editor,
image: nil
}
end
end
defp build_editor(post, metadata, language) do
defp build_editor(post, metadata, language, preview?) do
form = Draft.persisted_form(post, metadata, language)
textarea = ExRatatui.textarea_new()
:ok = ExRatatui.textarea_insert_str(textarea, form["content"] || "")
@@ -1495,7 +1503,7 @@ defmodule BDS.TUI do
title: form["title"] || "",
textarea: textarea,
editing_title?: false,
preview?: false,
preview?: preview?,
dirty: false
}
end
@@ -1505,7 +1513,7 @@ defmodule BDS.TUI do
defp reload_editor(%{editor: editor} = state) do
case Posts.get_post(editor.post.id) do
nil -> %{state | editor: nil, focus: :sidebar}
post -> %{state | editor: build_editor(post, editor.metadata, editor.language)}
post -> %{state | editor: build_editor(post, editor.metadata, editor.language, editor.preview?)}
end
end
@@ -1541,7 +1549,7 @@ defmodule BDS.TUI do
languages = Metadata.languages(editor.metadata)
index = Enum.find_index(languages, &(&1 == editor.language)) || 0
next = Enum.at(languages, rem(index + 1, length(languages)))
%{state | editor: build_editor(editor.post, editor.metadata, next)}
%{state | editor: build_editor(editor.post, editor.metadata, next, editor.preview?)}
end
end

View File

@@ -33,9 +33,12 @@ rule SidebarNavigation {
rule OpenEntry {
when: TuiKeyPressed(code: "enter")
-- Posts open in the editor (title + textarea seeded from the
-- persisted form). Images open in the terminal image preview.
-- Other entities report that terminal editing is not available yet.
-- Posts open in the rendered markdown preview by default (title +
-- textarea still seeded from the persisted form; ctrl+e switches to
-- editing). New empty posts created with "n" open in the editor
-- instead — there is nothing to preview yet. Images open in the
-- terminal image preview. Other entities report that terminal
-- editing is not available yet.
ensures: TuiState.editing_post.updated()
}
@@ -49,9 +52,12 @@ rule SaveAndPublish {
rule WrappedPreview {
when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- The editing textarea cannot soft-wrap (upstream ratatui-textarea
-- limitation), so ctrl+e toggles a read-only word-wrapped Markdown
-- preview of the current draft; keys in preview never edit content.
-- ctrl+e toggles between the read-only word-wrapped Markdown
-- preview (the default mode when opening a post — rendered through
-- tui-markdown so headings/emphasis/lists are styled) and the
-- editing textarea (which cannot soft-wrap, an upstream
-- ratatui-textarea limitation); keys in preview never edit content,
-- and esc returns to the sidebar from either mode.
ensures: PreviewToggled()
}

View File

@@ -68,13 +68,27 @@ defmodule BDS.TUITest do
assert text =~ "Hello TUI"
end
test "navigates and opens a post in the editor", %{post: post} do
test "opening a post lands in the markdown preview, not the editor", %{post: post} do
{:ok, _} =
BDS.Posts.update_post(post.id, %{content: "# Big Heading\n\nsome **bold** words"})
state = mount!() |> press("enter")
assert state.focus == :editor
assert state.editor.post.id == post.id
assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body"
assert screen_text(state) =~ "terminal body"
assert state.editor.preview?
text = screen_text(state)
assert text =~ "Big Heading"
# Rendered markdown: inline markers are consumed by the styling
# (headings keep their # but get styled, invisible in a text dump).
assert text =~ "some bold words"
refute text =~ "**bold**"
# ctrl+e drops into the editing textarea with the raw source.
state = press(state, "e", ["ctrl"])
refute state.editor.preview?
assert ExRatatui.textarea_get_value(state.editor.textarea) =~ "**bold**"
end
test "ctrl+s persists textarea edits", %{post: post} do
@@ -95,13 +109,16 @@ defmodule BDS.TUITest do
assert state.editor.post.status == :published
end
test "n creates a new draft post", %{project: project} do
test "n creates a new draft post and starts in the editor, not the preview", %{
project: project
} do
count_before = post_count(project.id)
state = mount!() |> press("n")
assert post_count(project.id) == count_before + 1
assert state.focus == :editor
assert state.editor.post.status == :draft
refute state.editor.preview?
end
test "esc returns focus to the sidebar" do
@@ -109,11 +126,11 @@ defmodule BDS.TUITest do
assert state.focus == :sidebar
end
test "ctrl+e toggles a word-wrapped preview of the draft", %{post: post} do
test "the preview word-wraps and ctrl+e toggles back and forth", %{post: post} do
long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line})
state = mount!() |> press("enter") |> press("e", ["ctrl"])
state = mount!() |> press("enter")
assert state.editor.preview?
# The textarea cannot soft-wrap, so the tail of a long line is off-screen
@@ -122,10 +139,14 @@ defmodule BDS.TUITest do
state = press(state, "e", ["ctrl"])
refute state.editor.preview?
state = press(state, "e", ["ctrl"])
assert state.editor.preview?
end
test "keys in preview mode do not edit the content" do
state = mount!() |> press("enter") |> press("e", ["ctrl"])
state = mount!() |> press("enter")
assert state.editor.preview?
before = ExRatatui.textarea_get_value(state.editor.textarea)
state = press(state, "x")
@@ -133,11 +154,12 @@ defmodule BDS.TUITest do
refute state.editor.dirty
end
test "esc in preview returns to editing, not to the sidebar" do
state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
test "esc returns to the sidebar from both preview and editor" do
state = mount!() |> press("enter") |> press("esc")
assert state.focus == :sidebar
refute state.editor.preview?
assert state.focus == :editor
state = state |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
assert state.focus == :sidebar
end
test "entity_changed refreshes the sidebar", %{project: project} do