diff --git a/builtin/runtime/linewrap.lua b/builtin/runtime/linewrap.lua new file mode 100644 index 0000000..1c68d51 --- /dev/null +++ b/builtin/runtime/linewrap.lua @@ -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, +} diff --git a/src/daemon.rs b/src/daemon.rs index 62cacc8..fff5e21 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -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 diff --git a/src/editor.rs b/src/editor.rs index c91659a..5824a52 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -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; } diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 77bb4c7..b2de320 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -672,6 +672,31 @@ pub fn config_u32(lua: &Lua, name: &str, buffer_id: Option, 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) -> crate::view::WrapMode { + let Some(registry) = lua.app_data_ref::() 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). /// diff --git a/src/semantic_render.rs b/src/semantic_render.rs index c7f5984..de9f417 100644 --- a/src/semantic_render.rs +++ b/src/semantic_render.rs @@ -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 { + 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