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

View File

@@ -33,9 +33,12 @@ rule SidebarNavigation {
rule OpenEntry { rule OpenEntry {
when: TuiKeyPressed(code: "enter") when: TuiKeyPressed(code: "enter")
-- Posts open in the editor (title + textarea seeded from the -- Posts open in the rendered markdown preview by default (title +
-- persisted form). Images open in the terminal image preview. -- textarea still seeded from the persisted form; ctrl+e switches to
-- Other entities report that terminal editing is not available yet. -- 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() ensures: TuiState.editing_post.updated()
} }
@@ -49,9 +52,12 @@ rule SaveAndPublish {
rule WrappedPreview { rule WrappedPreview {
when: TuiKeyPressed(code: "e", modifiers: "ctrl") when: TuiKeyPressed(code: "e", modifiers: "ctrl")
-- The editing textarea cannot soft-wrap (upstream ratatui-textarea -- ctrl+e toggles between the read-only word-wrapped Markdown
-- limitation), so ctrl+e toggles a read-only word-wrapped Markdown -- preview (the default mode when opening a post — rendered through
-- preview of the current draft; keys in preview never edit content. -- 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() ensures: PreviewToggled()
} }

View File

@@ -68,13 +68,27 @@ defmodule BDS.TUITest do
assert text =~ "Hello TUI" assert text =~ "Hello TUI"
end 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") state = mount!() |> press("enter")
assert state.focus == :editor assert state.focus == :editor
assert state.editor.post.id == post.id assert state.editor.post.id == post.id
assert ExRatatui.textarea_get_value(state.editor.textarea) == "terminal body" assert state.editor.preview?
assert screen_text(state) =~ "terminal body"
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 end
test "ctrl+s persists textarea edits", %{post: post} do test "ctrl+s persists textarea edits", %{post: post} do
@@ -95,13 +109,16 @@ defmodule BDS.TUITest do
assert state.editor.post.status == :published assert state.editor.post.status == :published
end 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) count_before = post_count(project.id)
state = mount!() |> press("n") state = mount!() |> press("n")
assert post_count(project.id) == count_before + 1 assert post_count(project.id) == count_before + 1
assert state.focus == :editor assert state.focus == :editor
assert state.editor.post.status == :draft assert state.editor.post.status == :draft
refute state.editor.preview?
end end
test "esc returns focus to the sidebar" do test "esc returns focus to the sidebar" do
@@ -109,11 +126,11 @@ defmodule BDS.TUITest do
assert state.focus == :sidebar assert state.focus == :sidebar
end 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" long_line = String.duplicate("lorem ipsum dolor sit amet ", 8) <> "FINALWORD"
{:ok, _} = BDS.Posts.update_post(post.id, %{content: long_line}) {: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? assert state.editor.preview?
# The textarea cannot soft-wrap, so the tail of a long line is off-screen # 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"]) state = press(state, "e", ["ctrl"])
refute state.editor.preview? refute state.editor.preview?
state = press(state, "e", ["ctrl"])
assert state.editor.preview?
end end
test "keys in preview mode do not edit the content" do 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) before = ExRatatui.textarea_get_value(state.editor.textarea)
state = press(state, "x") state = press(state, "x")
@@ -133,11 +154,12 @@ defmodule BDS.TUITest do
refute state.editor.dirty refute state.editor.dirty
end end
test "esc in preview returns to editing, not to the sidebar" do test "esc returns to the sidebar from both preview and editor" do
state = mount!() |> press("enter") |> press("e", ["ctrl"]) |> press("esc") state = mount!() |> press("enter") |> press("esc")
assert state.focus == :sidebar
refute state.editor.preview? state = state |> press("enter") |> press("e", ["ctrl"]) |> press("esc")
assert state.focus == :editor assert state.focus == :sidebar
end end
test "entity_changed refreshes the sidebar", %{project: project} do test "entity_changed refreshes the sidebar", %{project: project} do