From b6b1d16e54f7acddf1f3a3b5c1def69e7be40e8e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Thu, 16 Jul 2026 22:22:17 +0200 Subject: [PATCH] feat: posts open in the rendered markdown preview by default, ctrl+e toggles to the editor --- lib/bds/tui.ex | 34 ++++++++++++++++++++------------- specs/tui.allium | 18 ++++++++++++------ test/bds/tui_test.exs | 44 ++++++++++++++++++++++++++++++++----------- 3 files changed, 66 insertions(+), 30 deletions(-) diff --git a/lib/bds/tui.ex b/lib/bds/tui.ex index 68b36df..601f9ad 100644 --- a/lib/bds/tui.ex +++ b/lib/bds/tui.ex @@ -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 diff --git a/specs/tui.allium b/specs/tui.allium index 72abc8a..88c3364 100644 --- a/specs/tui.allium +++ b/specs/tui.allium @@ -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() } diff --git a/test/bds/tui_test.exs b/test/bds/tui_test.exs index 0586b9f..3da6d99 100644 --- a/test/bds/tui_test.exs +++ b/test/bds/tui_test.exs @@ -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