User expierience and UI design reworking #13

Merged
maleszka merged 44 commits from feature/progress into main 2026-07-05 19:26:30 +02:00
Owner
No description provided.
Replaces the static print-once hint with a per-frame line that always
states the live key bindings for the current focus, and switches key
dispatch (q/Esc/Backspace/Enter/printable chars) from input_buf.is_empty()
checks to explicit Focus::Queue/Focus::Query states. j/k/J/K/d don't do
anything in Focus::Queue yet — that lands with the queue view itself.
Merges the per-track row rendering with a ›/space cursor column and
view_offset-based windowing (ported from list.rs's compute_max_visible),
replacing the old sequential "…and N more" truncation. The cursor column
bumps the row's fixed-width budget by one, so compute_row_layout's fixed
costs are named COMPACT_FIXED_COST/FULL_FIXED_COST instead of inline
25/28. Cursor movement itself isn't wired to any key yet.
j/k move the cursor, J/K reorder, d removes, Enter promotes — the same
DownloadQueue operations list::manage_queue used, now live on the default
screen. Cursor-driven mutations debounce their state::save_queue call by
250ms (sharing RESIZE_DEBOUNCE) instead of writing once per keystroke,
flushed unconditionally on graceful exit. The promote-triggered pause
check runs inline right after every Enter-promote, since there's no
modal session boundary left to hang it on.
Tab and list::manage_queue/run_queue_inner/render_queue/QUEUE_SPINNER are
unreachable now that Focus::Queue's bindings live on the default screen.
Enables crossterm's bracketed-paste feature and handles Event::Paste from
either focus (the primary, authoritative path on any terminal that sends
it). For terminals that don't, printable characters are held in a short
buffer and flushed after a 15ms gap with nothing to extend the run —
paste-shaped runs (>=8 chars, >=2 distinct) switch to Focus::Query with
the accumulated text; anything else replays through dispatch_char exactly
as ordinary keystrokes, via the same path live typing now goes through.
Reordering an entry to the front via J/K (or removing the front entry via
d) now pauses the actively-downloading entry the same way Enter's promote
already does, restoring behavior the old Tab-modal's after-close check
used to provide unconditionally regardless of how the front entry got
there. Extracted the shared check into pause_for_new_front. Also bundled
the scattered focus/input_buf/cursor_idx/view_offset/pending_queue_save/
awaiting_promote_pause locals into a QueueUi struct, both to satisfy
clippy's too-many-arguments lint on dispatch_char/flush_key_burst and
because it was the state that check needed threaded through them.

Also: show the terminal's own cursor while typing in Focus::Query
(hidden the rest of the time, since the row's › marker is the only
cursor that means anything in Focus::Queue) via a new
sync_cursor_visibility call after every render.
ProgressRenderer::erase relied on a purely relative MoveUp(lines_drawn),
which a terminal's resize-triggered reflow of already-printed lines can
silently invalidate before the next erase runs, leaving stale header/
separator copies behind (worst on Alacritty). Adds resize_overshoot: u16,
bumped once per raw Event::Resize regardless of debounce settling and
folded into every erase on top of lines_drawn, so repeated resizing
converges the erase point upward (the same technique fzf's non-fullscreen
mode uses). Also widens ROW_MARGIN from an implicit 0 to 2 columns across
every printed line (header, separator, placeholder, and now entry rows via
compute_row_layout), narrowing how often a line lands on the terminal's
exact edge in the first place; COMPACT_LAYOUT_COLS moves 60 -> 62 to keep
the floor-fits invariant intact at the new margin.
Converts both pickers' blocking event::read() loops to a poll(timeout)-
then-read() shape so Event::Resize is no longer silently discarded mid-
picker: raw resize events bump a local resize_overshoot counter and reset
a RESIZE_DEBOUNCE deadline (now shared from ui::RESIZE_DEBOUNCE rather than
duplicated), and once that deadline elapses with nothing further arriving,
the stale frame is erased so the next iteration redraws at the terminal's
current size. max_visible is now recomputed every loop iteration instead
of once up front, so a mid-picker resize takes effect immediately rather
than needing a picker restart. Bundled prev_lines/resize_overshoot into an
EraseInput struct to keep render_track_list(_multi) under clippy's
too-many-arguments limit.

Also raises the narrow-terminal floor from 20 to 50 columns (matching
ProgressRenderer's MIN_TERMINAL_COLS) and shows the same "(terminal too
narrow)" placeholder below it, instead of attempting a row layout that
term_max_chars' old floor only guaranteed wouldn't go below 20 characters
of garbled content.
Every redraw cleared the whole previously-drawn block up front
(Clear(ClearType::FromCursorDown)) before reprinting it, all within one
buffered write/flush — an atomic operation from this process's point of
view, but not from a GPU-rendered terminal's (Alacritty, Kitty, WezTerm),
which present frames on their own render clock and can sample the grid in
the gap between the clear landing and the redraw landing. At up to 20 Hz
on the live progress bar, or on every queue-cursor keystroke, that reads
as continuous blinking.

Wraps each frame in crossterm's BeginSynchronizedUpdate/EndSynchronizedUpdate
(DEC private mode ?2026, already in crossterm 0.28 — no new dependency),
which holds a supporting terminal's presentation until the whole frame has
landed; unsupported terminals ignore the mode as a no-op, so this is safe
to send unconditionally. This is the same mechanism fzf uses to stay
flicker-free without an alternate screen.

Also replaces the upfront whole-block clear with overwrite-in-place: each
line is followed by Clear(ClearType::UntilNewLine) to trim its own stale
tail instead of blanking the block before redrawing it, and a trailing
Clear(ClearType::FromCursorDown) only fires when the new frame actually
ends higher than the old one did (a genuine shrink, or resize_overshoot
shifting the frame's start upward) — never on a row about to be
overwritten anyway. Applied uniformly to ProgressRenderer::render and both
list.rs pickers.
core_architecture.md's binary/library boundary table and invariant list
still named indicatif as a permitted binary-side dependency; it was
dropped in Stage 1 (be995e8) and never used from src/ after that. Found
while doing Stage 3's wrap-up check for contracts that need promoting.
render_with_prompt appends its "> {prompt}" line after render()'s own
per-line Clear(ClearType::UntilNewLine) treatment (added when the redraw-
flicker fix replaced the old upfront whole-block clear with per-line
trims), but that appended line was never given a trim of its own. A
Backspace/Delete that shortens the prompt left the previous, longer
string's tail visible on screen, since nothing was clearing it anymore -
the old upfront clear used to blank it by accident, not by design.

Adds a Clear(ClearType::UntilNewLine) right after printing the prompt
line, same discipline as every other line render() prints. Also fixes a
stale doc-comment reference to erase_lines, which Stage 3 task 4 already
renamed to move_to_frame_start.
QueueUi::input_buf was append-only: every typed character pushed onto
the end and Backspace always popped the last one, with no way to edit
anywhere but the end. Adds input_cursor: usize (a char, not byte, index,
since queries can contain multi-byte text like artist names) alongside
input_buf, and wires it up:

- Left/Right move the cursor one character at a time.
- Ctrl+Left/Ctrl+Right jump a bash/readline-style word at a time (a word
  is a maximal non-whitespace run, so punctuation stays part of the same
  word as adjacent non-whitespace).
- Delete removes the character ahead of the cursor.
- Backspace is fixed to remove the character behind the cursor instead of
  always the buffer's last character.
- Typing now inserts at the cursor rather than always appending.

Every existing input_buf mutation site (paste-fill, Esc-cancel,
Enter-submit) now keeps input_cursor in sync. char_byte_index converts a
char index to the byte offset String::insert/remove need, so no edit
operation can land mid-codepoint. render_with_prompt does not yet
reflect the cursor position visually - that's the next commit.
Now that input_cursor can be anywhere in input_buf (not just the end),
leaving the real terminal cursor wherever the last Print landed it -
always the end of the printed line - would make editing anywhere but the
end invisible, effectively blind-editing.

render_with_prompt takes a new PromptState { text, cursor } parameter
(bundled to stay under clippy's too-many-arguments limit, same fix
list.rs already used for EraseInput) instead of a bare &str, and moves
the real cursor left by the character count between it and the end of
the line after printing. MoveLeft(0) is skipped deliberately, since some
terminals treat a 0 count the same as an absent one.
maleszka deleted branch feature/progress 2026-07-05 19:26:31 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
maleszka/librespot-dl!13
No description provided.