feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29)

This commit is contained in:
2026-07-17 08:38:26 +02:00
parent b6b1d16e54
commit 505527ae4f
13 changed files with 2572 additions and 672 deletions

View File

@@ -10,7 +10,11 @@ defmodule BDS.TUI do
## Keys
Sidebar focus: `↑/↓`/`j/k` navigate, `enter` open, `n` new post,
`1..5` switch view (posts/media/templates/scripts/tags), `7` git panel
`1..5` switch view (posts/media/templates/scripts/tags), `6` settings
(the GUI preferences sections as sidebar entries; enter opens a
section's form — enter edits a text field in a status-line prompt,
toggles a boolean or cycles an enum, `ctrl+s` saves through the same
backends as the GUI, esc closes), `7` git panel
(changed files plus commit history in the sidebar, scrollable
whole-folder diff in the main area; `c` commit all, `u` pull, `s` push,
enter jumps the diff to the selected file), `r` refresh,
@@ -39,13 +43,14 @@ defmodule BDS.TUI do
alias BDS.Posts
alias BDS.Projects
alias BDS.UI.PostEditor.{Draft, Metadata, Persistence}
alias BDS.UI.SettingsForm
alias BDS.UI.Sidebar
alias ExRatatui.Layout
alias ExRatatui.Layout.Rect
alias ExRatatui.Style
alias ExRatatui.Widgets.{Block, Clear, List, Markdown, Paragraph, Tabs, Textarea}
@views ~w(posts media templates scripts tags git)
@views ~w(posts media templates scripts tags settings git)
@git_diff_scroll_step 10
@git_max_diff_lines 5000
@@ -72,6 +77,7 @@ defmodule BDS.TUI do
filters_by_view: %{},
git: nil,
git_commit: nil,
settings_form: nil,
report: nil,
handled_task_ids: nil,
task_polling: false,
@@ -139,6 +145,10 @@ defmodule BDS.TUI do
when search != nil,
do: search_key(key, state)
def handle_event(%ExRatatui.Event.Key{kind: "press"} = key, %{settings_form: form} = state)
when form != nil,
do: settings_form_key(key, state)
def handle_event(%ExRatatui.Event.Key{code: "esc"}, %{image: image} = state)
when image != nil,
do: {:noreply, %{state | image: nil}}
@@ -159,15 +169,13 @@ defmodule BDS.TUI do
defp sidebar_key(%{code: code}, state) when code in ["up", "k"],
do: {:noreply, move_selection(state, -1)}
defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5) do
# Settings is panel "6" (issue #29) and git panel "7" (issue #30),
# matching the GUI panel numbering.
defp sidebar_key(%{code: code}, state) when code in ~w(1 2 3 4 5 6 7) do
view = Enum.at(@views, String.to_integer(code) - 1)
{:noreply, load_sidebar(%{state | view: view, selected: 0, image: nil})}
end
# Git is panel "7", matching the GUI panel numbering (issue #30).
defp sidebar_key(%{code: "7"}, state),
do: {:noreply, load_sidebar(%{state | view: "git", selected: 0, image: nil})}
defp sidebar_key(%{code: "c"}, %{view: "git"} = state) do
if state.git do
{:noreply, %{state | git_commit: %{input: ""}}}
@@ -230,6 +238,8 @@ defmodule BDS.TUI do
%{route: "post", id: post_id} -> {:noreply, open_post(state, post_id)}
%{route: "media", id: media_id} -> {:noreply, open_media(state, media_id)}
%{route: "git_file", id: path} -> {:noreply, jump_to_file_diff(state, path)}
%{route: "settings", id: "settings-" <> section} -> {:noreply, open_settings_form(state, section)}
%{route: "style"} -> {:noreply, open_settings_form(state, "style")}
%{id: _id} -> {:noreply, toast(state, view_label(state.view), dgettext("ui", "Editing this item is not available in the terminal UI yet."))}
_other -> {:noreply, state}
end
@@ -397,6 +407,153 @@ defmodule BDS.TUI do
defp format_git_error({:git_failed, output}), do: output
defp format_git_error(reason), do: inspect(reason)
# ── Events: settings form (issue #29) ─────────────────────────────────────
# A settings sidebar entry opens its section as a generic typed-field
# form in the main area, backed by BDS.UI.SettingsForm — the same
# preferences the GUI settings editor operates on.
defp open_settings_form(state, section) do
form = SettingsForm.load(section, state.project_id)
%{
state
| settings_form: %{
section: form.section,
title: form.title,
fields: form.fields,
selected: seek_field(form.fields, 0, 1),
input: nil,
dirty: false
},
image: nil
}
end
# Text fields edit in a status-line prompt (like the commit message):
# enter commits the value into the field, esc cancels just the prompt.
defp settings_form_key(%{code: "esc"}, %{settings_form: %{input: input}} = state)
when input != nil,
do: {:noreply, put_in(state.settings_form.input, nil)}
defp settings_form_key(%{code: "enter"}, %{settings_form: %{input: input} = form} = state)
when input != nil do
fields =
Enum.map(form.fields, fn field ->
if field.key == input.key, do: %{field | value: input.value}, else: field
end)
{:noreply, %{state | settings_form: %{form | fields: fields, input: nil, dirty: true}}}
end
defp settings_form_key(%{code: "backspace"}, %{settings_form: %{input: input}} = state)
when input != nil do
{:noreply, put_in(state.settings_form.input.value, String.slice(input.value, 0..-2//1))}
end
defp settings_form_key(%{code: code, modifiers: []}, %{settings_form: %{input: input}} = state)
when input != nil and byte_size(code) >= 1 do
if String.length(code) == 1 do
{:noreply, put_in(state.settings_form.input.value, input.value <> code)}
else
{:noreply, state}
end
end
defp settings_form_key(_key, %{settings_form: %{input: input}} = state) when input != nil,
do: {:noreply, state}
defp settings_form_key(%{code: "esc"}, state),
do: {:noreply, %{state | settings_form: nil}}
defp settings_form_key(%{code: "s", modifiers: ["ctrl"]}, state),
do: save_settings_form(state)
defp settings_form_key(%{code: code}, state) when code in ["down", "j"],
do: {:noreply, move_field_selection(state, 1)}
defp settings_form_key(%{code: code}, state) when code in ["up", "k"],
do: {:noreply, move_field_selection(state, -1)}
defp settings_form_key(%{code: "enter"}, %{settings_form: form} = state) do
case Enum.at(form.fields, form.selected) do
%{type: :text} = field ->
{:noreply,
put_in(state.settings_form.input, %{key: field.key, label: field.label, value: field.value})}
%{type: :bool} = field ->
{:noreply, update_field(state, field.key, not field.value)}
%{type: :enum, options: options} = field when options != [] ->
index = Enum.find_index(options, &(&1 == field.value)) || 0
{:noreply, update_field(state, field.key, Enum.at(options, rem(index + 1, length(options))))}
_other ->
{:noreply, state}
end
end
defp settings_form_key(_key, state), do: {:noreply, state}
defp update_field(%{settings_form: form} = state, key, value) do
fields =
Enum.map(form.fields, fn field ->
if field.key == key, do: %{field | value: value}, else: field
end)
%{state | settings_form: %{form | fields: fields, dirty: true}}
end
defp save_settings_form(%{settings_form: form} = state) do
values = Map.new(form.fields, fn field -> {field.key, field.value} end)
case SettingsForm.save(form.section, state.project_id, values) do
:ok ->
reloaded = SettingsForm.load(form.section, state.project_id)
state = %{
state
| settings_form: %{
form
| fields: reloaded.fields,
selected: min(form.selected, max(length(reloaded.fields) - 1, 0)),
input: nil,
dirty: false
}
}
{:noreply, toast(state, form.title, dgettext("ui", "Saved."))}
{:error, reason} ->
{:noreply, toast(state, form.title, settings_error(reason))}
end
end
defp settings_error(reason) when is_binary(reason), do: reason
defp settings_error(reason), do: inspect(reason)
# Selection skips read-only :info rows in the given direction.
defp move_field_selection(%{settings_form: form} = state, delta) do
count = length(form.fields)
if count == 0 do
state
else
next = seek_field(form.fields, clamp(form.selected + delta, count), delta)
put_in(state.settings_form.selected, next)
end
end
defp seek_field(fields, index, delta) do
case Enum.at(fields, index) do
%{type: :info} ->
next = clamp(index + sign(delta), length(fields))
if next == index, do: index, else: seek_field(fields, next, delta)
_other ->
index
end
end
# ── Events: sidebar search (vi-style "/") ─────────────────────────────────
# The prompt lives in the status line and filters the current view live
@@ -970,6 +1127,25 @@ defmodule BDS.TUI do
defp list_selected([], _selected), do: nil
defp list_selected(items, selected), do: min(selected, length(items) - 1)
defp main_widgets(%{settings_form: form}, rect) when form != nil do
items = Enum.map(form.fields, &field_line/1)
title =
form.title <>
if(form.dirty, do: "", else: "") <>
"" <> dgettext("ui", "enter edit · ctrl+s save · esc close")
[
{%List{
items: items,
selected: list_selected(items, form.selected),
scroll_padding: 2,
highlight_style: %Style{fg: :black, bg: :cyan},
block: %Block{title: title, borders: [:all]}
}, rect}
]
end
defp main_widgets(%{view: "git", editor: nil, git: nil}, rect) do
[
{%Paragraph{
@@ -1055,8 +1231,11 @@ defmodule BDS.TUI do
end
defp status_widgets(state, rect) do
settings_input = state.settings_form && state.settings_form.input
text =
cond do
settings_input -> settings_input.label <> ": " <> settings_input.value
state.git_commit -> dgettext("ui", "Commit message") <> ": " <> state.git_commit.input
state.search -> "/" <> state.search.input
state.busy -> dgettext("ui", "Working…")
@@ -1315,18 +1494,25 @@ defmodule BDS.TUI do
defp truncate(text) when byte_size(text) > 40, do: String.slice(text, 0, 40) <> ""
defp truncate(text), do: text
defp default_status(%{settings_form: form}) when form != nil,
do:
dgettext(
"ui",
"enter edit/toggle/cycle · ctrl+s save · esc close · ctrl+q quit"
)
defp default_status(%{focus: :sidebar, view: "git"}),
do:
dgettext(
"ui",
"c commit · u pull · s push · enter jump to file · pgup/pgdn scroll diff · 1-5 views · ctrl+q quit"
"c commit · u pull · s push · enter jump to file · pgup/pgdn scroll diff · 1-7 views · ctrl+q quit"
)
defp default_status(%{focus: :sidebar}),
do:
dgettext(
"ui",
"enter open · n new post · 1-5 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit"
"enter open · n new post · 1-7 views · p projects · / search · : commands (:? help) · r refresh · ctrl+q quit"
)
defp default_status(%{focus: :editor}),
@@ -1453,6 +1639,12 @@ defmodule BDS.TUI do
end
end
defp field_line(%{type: :bool} = field),
do: if(field.value, do: "[x] ", else: "[ ] ") <> field.label
defp field_line(%{type: :info, value: ""} = field), do: field.label
defp field_line(field), do: "#{field.label}: #{field.value}"
defp item_label(item) do
title =
item[:title] || item[:name] || item[:original_name] || item[:label] || item[:id] || "?"
@@ -1466,6 +1658,7 @@ defmodule BDS.TUI do
defp view_label("templates"), do: dgettext("ui", "Templates")
defp view_label("scripts"), do: dgettext("ui", "Scripts")
defp view_label("tags"), do: dgettext("ui", "Tags")
defp view_label("settings"), do: dgettext("ui", "Settings")
defp view_label("git"), do: dgettext("ui", "Git")
# ── Post editor ──────────────────────────────────────────────────────────