feat: ui.line-wrap, resolved once and told to both frontends
The setting exists now, and both frontends honor it. This is the commit that turns the stage on: the grid renderer, the coordinate mapping, and the GPU were all built and tested against a mode nothing could set. ui.line-wrap is buffer-local, an enum of wrap and truncate, defaulting to wrap. A closed set rather than a string, so an unknown value is impossible rather than handled --- and so adding "word" later is additive. It lives in ui., not editing.: editing.* is buffer-editing behavior, this changes only how text is SHOWN, and the two existing ui.* settings carry a gpu- prefix to mark frontend-specific ones, so the absence of a prefix is what says "both frontends". Resolved exactly once. The render loop reads it per window per frame and records it on the window; the viewport is built FROM that, and Window::layout_ctx hands the same answer to all twenty coordinate call sites. Nothing re-resolves, so two callers cannot disagree about one buffer. The earlier write-back from viewport to window is removed as circular now that the resolution precedes the viewport. Semantic frontends are told over LineWrapFacts, gated at v22 in the producer and again in the daemon write loop. The dedup key is the (buffer, wrap) PAIR, and that is the whole design rather than a micro-optimisation: font size is global, so caching it by value is right, but wrap mode is buffer-local, so a value-keyed cache stays silent when the user switches from a truncating buffer to a wrapping one --- a real mode change with no config event behind it. a_buffer_switch_re_emits_the_wrap_mode pins that, and it bites: keying the cache on the mode alone fails it while every other test still passes, which is exactly how the bug would have shipped. Two existing tests moved, and both moved for the right reason rather than because they were stale. The semantic allowlist gains the variant, and the first-frame count goes 7 to 8 --- the second is really an assertion that the attach trigger works: a semantic frontend that is not told on its first frame never learns the setting at all. Also ships ui.toggle-line-wrap, and its status line says the quiet part --- turning wrapping off makes text past the right edge unreachable until horizontal scrolling lands in Stage 4. That belongs where a user sees it, not only in the framing. Gates: fmt, workspace clippy -D warnings, diff --check, --lib 1916/0, crdt 2101/0, pmacs-gpu 224/0, tab_width 2/0, folding 21/0, gui_zoom 15/15, full_grid_resync 1/1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
This commit is contained in:
parent
e755866644
commit
d8cf809b97
|
|
@ -0,0 +1,59 @@
|
|||
-- Long lines (QoL Stage 3, framing docs/long-lines-framing.md).
|
||||
--
|
||||
-- Declares `ui.line-wrap`. Everything that honors it is Rust: the grid
|
||||
-- renderer walks it through `Viewport`, the coordinate mapping takes it
|
||||
-- through `LayoutCtx`, and semantic frontends are told over
|
||||
-- `InstanceMessage::LineWrapFacts` at protocol v22 because they lay out
|
||||
-- locally and would otherwise never hear it.
|
||||
--
|
||||
-- Buffer-local (Q#LL2). The registry already supports a per-buffer
|
||||
-- layer, and this is a property of the content: prose wants wrapping,
|
||||
-- a log file usually does not. The *anchor* the viewport scrolls to
|
||||
-- stays per-window, because two panes on one buffer scroll
|
||||
-- independently.
|
||||
--
|
||||
-- `ui.`, not `editing.`: `editing.*` is buffer-editing behavior
|
||||
-- (auto-pair, trim-on-save, line endings) and this changes only how
|
||||
-- text is shown. The two existing `ui.*` settings carry a `gpu-`
|
||||
-- prefix to mark frontend-specific ones, so the ABSENCE of a prefix
|
||||
-- here is what says "both frontends".
|
||||
|
||||
pmacs.config.define {
|
||||
name = "ui.line-wrap",
|
||||
description = "How a line wider than the window is shown: wrap onto following rows, or truncate at the edge.",
|
||||
-- A closed set, so an unknown value is impossible rather than
|
||||
-- handled. Adding "word" later is a clean additive change --- which
|
||||
-- is the plan, since character wrap is what both frontends can do
|
||||
-- identically today (Q#LL5) and word wrap is a deliberate future
|
||||
-- choice rather than an inherited library default.
|
||||
type = "enum",
|
||||
choices = { "wrap", "truncate" },
|
||||
-- `wrap` is the only value that leaves every character reachable
|
||||
-- with this stage's machinery. It is also what the GPU already did,
|
||||
-- so the default is not a behavior change there --- but it IS one in
|
||||
-- the TUI, which truncated. No default can preserve both, because
|
||||
-- the two frontends disagreed before this setting existed; that is
|
||||
-- the defect, not a side effect of fixing it.
|
||||
default = "wrap",
|
||||
mutability = "live",
|
||||
}
|
||||
|
||||
-- `truncate` leaves text past the right edge UNREACHABLE until Stage 4
|
||||
-- adds horizontal scrolling. That is stated in the description above
|
||||
-- rather than left for a user to discover, and it is why `truncate` is
|
||||
-- not the default despite being the TUI's historical behavior.
|
||||
|
||||
pmacs.command.define {
|
||||
name = "ui.toggle-line-wrap",
|
||||
description = "Toggle line wrapping for the current buffer",
|
||||
fn = function()
|
||||
local current = pmacs.config.get("ui.line-wrap")
|
||||
local next_mode = current == "wrap" and "truncate" or "wrap"
|
||||
pmacs.config.set("ui.line-wrap", next_mode)
|
||||
if next_mode == "truncate" then
|
||||
pmacs.editor.set_status("line wrap off — text past the edge is unreachable until horizontal scrolling lands")
|
||||
else
|
||||
pmacs.editor.set_status("line wrap on")
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
|
@ -1493,6 +1493,14 @@ fn dispatcher_loop(
|
|||
if !peer_knows_font_facts && matches!(msg, InstanceMessage::FontFacts { .. }) {
|
||||
continue;
|
||||
}
|
||||
// Long lines — LineWrapFacts gated at v22. A v21 peer
|
||||
// keeps whatever it does today; the semantic producer
|
||||
// also skips it, so this is the belt-and-braces half.
|
||||
if negotiated_protocol_version < 22
|
||||
&& matches!(msg, InstanceMessage::LineWrapFacts { .. })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// Vterm Stage 3 — TerminalFrame gated at v19. A v18
|
||||
// semantic peer keeps the empty identity snapshot and
|
||||
// no terminal surface; a v18 grid peer is unaffected
|
||||
|
|
|
|||
|
|
@ -763,6 +763,12 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/zoom.lua"),
|
||||
)
|
||||
.expect("load zoom builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/linewrap.lua"),
|
||||
include_str!("../builtin/runtime/linewrap.lua"),
|
||||
)
|
||||
.expect("load linewrap builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
@ -4376,8 +4382,10 @@ fn paint_window_content(
|
|||
// viewport itself rather than recomputed: a second derivation could
|
||||
// disagree with the one the renderer used, and the disagreement
|
||||
// would only show as a cursor on the wrong row.
|
||||
// Width only: `last_wrap` was resolved before the viewport was
|
||||
// built and is what the viewport was built FROM, so writing it back
|
||||
// here would be circular.
|
||||
window.last_content_cols = viewport.cell_size.cols;
|
||||
window.last_wrap = viewport.wrap;
|
||||
window.text_view.render(buf, viewport, grid);
|
||||
if gutter_w > 0 {
|
||||
paint_line_number_gutter(grid, window, &rect, inner_rows, gutter_w, folds, theme);
|
||||
|
|
@ -4574,6 +4582,13 @@ pub fn paint_frame(
|
|||
// Record viewport height for page motion (cursor.page-down /
|
||||
// cursor.page-up consume this).
|
||||
window.last_visible_rows = inner_rows;
|
||||
// Resolve the buffer's wrap mode once per window per frame and
|
||||
// record it here. Every later consumer — the viewport below,
|
||||
// and the coordinate callers via `Window::layout_ctx` — reads
|
||||
// this one answer, so nothing re-resolves and two callers
|
||||
// cannot disagree about one buffer.
|
||||
window.last_wrap =
|
||||
crate::lua_bindings::config_line_wrap(state.lua_host.lua(), Some(window.buffer_id));
|
||||
if inner_rows == 0 || rect.size.cols == 0 {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -672,6 +672,31 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option<BufferId>, fallback:
|
|||
}
|
||||
}
|
||||
|
||||
/// Resolve `ui.line-wrap` for `buffer_id`.
|
||||
///
|
||||
/// The registry has no ambient current buffer by design, so the caller
|
||||
/// names the buffer; passing `None` reads the global layer.
|
||||
///
|
||||
/// Falls back to [`WrapMode::Wrap`] rather than `Truncate`, and the
|
||||
/// direction matters: the fallback covers a bare core whose runtime
|
||||
/// never loaded the builtin, and it must agree with the setting's own
|
||||
/// default or a test-constructed editor would silently render in the
|
||||
/// mode nobody chose — which is the defect this whole stage exists to
|
||||
/// remove.
|
||||
#[must_use]
|
||||
pub fn config_line_wrap(lua: &Lua, buffer_id: Option<BufferId>) -> crate::view::WrapMode {
|
||||
let Some(registry) = lua.app_data_ref::<config::SharedConfigRegistry>() else {
|
||||
return crate::view::WrapMode::Wrap;
|
||||
};
|
||||
let borrowed = registry.borrow();
|
||||
match borrowed.get("ui.line-wrap", buffer_id) {
|
||||
Ok(crate::config_registry::ConfigValue::Str(v)) if v == "truncate" => {
|
||||
crate::view::WrapMode::Truncate
|
||||
}
|
||||
_ => crate::view::WrapMode::Wrap,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a `String` setting plus the registry epoch that keys any cache
|
||||
/// built from it (Q#TC4c).
|
||||
///
|
||||
|
|
|
|||
|
|
@ -279,6 +279,12 @@ pub struct SemanticRenderState {
|
|||
/// font state, so this gate has no summary-style companion
|
||||
/// filter.
|
||||
peer_knows_font_facts: bool,
|
||||
/// `LineWrapFacts` is v22; a v21 peer keeps its own behavior.
|
||||
peer_knows_line_wrap: bool,
|
||||
/// Last `(buffer, wrap)` pair sent. Keyed on the PAIR, not the
|
||||
/// mode: that is what makes a BUFFER SWITCH re-emit without a
|
||||
/// config event, which a value-keyed cache would miss entirely.
|
||||
last_line_wrap: Option<(crate::buffer::BufferId, bool)>,
|
||||
/// Whether the peer negotiated protocol >= 18 (Q#SL7). This gates
|
||||
/// callback evaluation in the producer, independently of the daemon's
|
||||
/// write-loop gate.
|
||||
|
|
@ -471,6 +477,7 @@ impl SemanticRenderState {
|
|||
let mut s = Self::new(frontend_id);
|
||||
s.peer_knows_theme_facts = negotiated_protocol_version >= 16;
|
||||
s.peer_knows_font_facts = negotiated_protocol_version >= 17;
|
||||
s.peer_knows_line_wrap = negotiated_protocol_version >= 22;
|
||||
s.peer_knows_statusline_segments = negotiated_protocol_version >= 18;
|
||||
s.peer_knows_terminal_frames = negotiated_protocol_version >= 19;
|
||||
s.peer_knows_panel_frames = negotiated_protocol_version >= PANEL_MIN_VERSION;
|
||||
|
|
@ -517,6 +524,8 @@ impl SemanticRenderState {
|
|||
last_font_epoch: None,
|
||||
last_font_facts: None,
|
||||
peer_knows_font_facts: true,
|
||||
peer_knows_line_wrap: true,
|
||||
last_line_wrap: None,
|
||||
peer_knows_statusline_segments: true,
|
||||
last_statusline: HashMap::new(),
|
||||
diag_line_cache: HashMap::new(),
|
||||
|
|
@ -952,6 +961,7 @@ impl SemanticRenderState {
|
|||
// --- ThemeFacts (UI faces; themes arc Q#TH7, protocol v16) ---
|
||||
out.extend(self.theme_facts_msg(state));
|
||||
out.extend(self.font_facts_msg(state));
|
||||
out.extend(self.line_wrap_msg(state, vp.buffer_id));
|
||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
||||
// Parent acceptance 45: ONE provider invocation supplies both the
|
||||
// primary-document wire segments and the panel mode line, so the
|
||||
|
|
@ -1100,6 +1110,7 @@ impl SemanticRenderState {
|
|||
out.extend(self.minibuffer_prompt_msg(state, buffer_id));
|
||||
out.extend(self.theme_facts_msg(state));
|
||||
out.extend(self.font_facts_msg(state));
|
||||
out.extend(self.line_wrap_msg(state, buffer_id));
|
||||
// Q#SL6/Q#SL8: face inventory must precede segment text.
|
||||
// The band rides the terminal path too: a frontend whose DOCUMENT
|
||||
// surface is a full-window terminal can still hold a side window,
|
||||
|
|
@ -2036,6 +2047,38 @@ impl SemanticRenderState {
|
|||
})
|
||||
}
|
||||
|
||||
/// The wrap mode for the buffer this session is showing (v22).
|
||||
///
|
||||
/// `pmacs-gpu` lays out locally and ignores the grid family, so
|
||||
/// without this it would never hear `ui.line-wrap` at all and would
|
||||
/// keep wrapping while the TUI truncated — the cross-frontend
|
||||
/// disagreement the long-lines stage exists to close.
|
||||
///
|
||||
/// Deduped on the `(buffer, wrap)` PAIR. That is not a
|
||||
/// micro-optimisation: the mode is buffer-local, so switching from a
|
||||
/// truncating buffer to a wrapping one changes the effective mode
|
||||
/// with **no config event at all**. A cache keyed on the mode alone
|
||||
/// would stay silent through exactly that transition — and would
|
||||
/// look correct in every single-buffer test.
|
||||
fn line_wrap_msg(
|
||||
&mut self,
|
||||
state: &EditorState,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
) -> Option<InstanceMessage> {
|
||||
if !self.peer_knows_line_wrap {
|
||||
return None;
|
||||
}
|
||||
let wrap = matches!(
|
||||
crate::lua_bindings::config_line_wrap(state.lua_host.lua(), Some(buffer_id)),
|
||||
crate::view::WrapMode::Wrap
|
||||
);
|
||||
if self.last_line_wrap == Some((buffer_id, wrap)) {
|
||||
return None;
|
||||
}
|
||||
self.last_line_wrap = Some((buffer_id, wrap));
|
||||
Some(InstanceMessage::LineWrapFacts { buffer_id, wrap })
|
||||
}
|
||||
|
||||
/// Project the [`Decoration`] set intersecting the declared
|
||||
/// viewport: the session's selection (instance-authoritative,
|
||||
/// byte-native) and LSP diagnostics (line/col → byte, severity →
|
||||
|
|
@ -3738,6 +3781,7 @@ mod tests {
|
|||
| InstanceMessage::LineNumbers { .. }
|
||||
| InstanceMessage::ThemeFacts { .. }
|
||||
| InstanceMessage::FontFacts { .. }
|
||||
| InstanceMessage::LineWrapFacts { .. }
|
||||
| InstanceMessage::StatuslineSegments { .. }
|
||||
),
|
||||
"semantic projection emitted an unexpected variant: {m:?}"
|
||||
|
|
@ -3911,13 +3955,17 @@ mod tests {
|
|||
// StatusFacts (Q#S1, cached-compare), the authoritative
|
||||
// ThemeFacts table (Q#TH7 — empty for an unthemed daemon), and
|
||||
// the authoritative FontFacts preference (Q#F5 — all-default), and
|
||||
// authoritative empty statusline segments (Q#SL8).
|
||||
// authoritative empty statusline segments (Q#SL8), and the
|
||||
// buffer's authoritative wrap mode (v22 — a semantic frontend
|
||||
// lays out locally, so it has to be told on the first frame or
|
||||
// it never learns the setting at all).
|
||||
let first = s.render_frame(&state);
|
||||
assert_eq!(
|
||||
first.len(),
|
||||
7,
|
||||
8,
|
||||
"first frame ships StyleSpans + Decorations + FileStyleSummary \
|
||||
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments"
|
||||
+ StatusFacts + ThemeFacts + FontFacts + StatuslineSegments \
|
||||
+ LineWrapFacts"
|
||||
);
|
||||
assert_semantic_only(&first);
|
||||
let (style_full, _) = style_segments(&first).expect("StyleSpans present");
|
||||
|
|
@ -4606,6 +4654,56 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A buffer switch re-emits the wrap mode, with no config event.
|
||||
///
|
||||
/// This is the trigger a `FontFacts`-shaped design misses. Font size
|
||||
/// is global, so caching it by value is right; wrap mode is
|
||||
/// **buffer-local**, so a value-keyed cache stays silent when the
|
||||
/// user moves from one buffer to another with a different mode —
|
||||
/// and looks perfectly correct in every single-buffer test.
|
||||
///
|
||||
/// Keying the cache on the `(buffer, wrap)` pair is what makes the
|
||||
/// switch re-emit, so that is what this pins.
|
||||
#[test]
|
||||
fn a_buffer_switch_re_emits_the_wrap_mode() {
|
||||
let state = empty_state();
|
||||
let mut sem = local();
|
||||
let first = active_buffer(&state);
|
||||
let second = crate::buffer::BufferId::from_raw(first.raw() + 1);
|
||||
|
||||
let a = sem
|
||||
.line_wrap_msg(&state, first)
|
||||
.expect("the first buffer's mode is authoritative");
|
||||
assert!(matches!(
|
||||
a,
|
||||
InstanceMessage::LineWrapFacts { buffer_id, .. } if buffer_id == first
|
||||
));
|
||||
assert!(
|
||||
sem.line_wrap_msg(&state, first).is_none(),
|
||||
"the same pair is suppressed"
|
||||
);
|
||||
|
||||
let b = sem
|
||||
.line_wrap_msg(&state, second)
|
||||
.expect("a different buffer must be told, even at the same mode");
|
||||
assert!(matches!(
|
||||
b,
|
||||
InstanceMessage::LineWrapFacts { buffer_id, .. } if buffer_id == second
|
||||
));
|
||||
}
|
||||
|
||||
/// A pre-v22 peer is never sent the variant.
|
||||
#[test]
|
||||
fn a_v21_peer_is_not_told_about_wrapping() {
|
||||
let state = empty_state();
|
||||
let mut sem = local();
|
||||
sem.peer_knows_line_wrap = false;
|
||||
assert!(
|
||||
sem.line_wrap_msg(&state, active_buffer(&state)).is_none(),
|
||||
"gated at v22; an older peer keeps its own behavior"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_of_render_state_reads_same_editor_state() {
|
||||
// The dispatcher selects the projection per session, not per
|
||||
|
|
|
|||
Loading…
Reference in New Issue