diff --git a/docs/architecture.md b/docs/architecture.md index aae25e1..7199360 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,7 @@ Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text. Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated ordered left/center/right segment groups and effective segment styles, and `label(name)` for session-wide custom values. The TUI owns terminal-cell widths, full-row center anchoring, clipping, and accent styling. Label effects carry only a name and plain text; they are global presentation updates, not buffer/revision-bound results or render callbacks. +Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. Frontends inspect `diagnostics()` and `diagnostic_at_cursor()` for validated active-buffer annotations. Diagnostics are owned by core LSP or a plugin and buffer revision; text changes clear them. Native server retirement removes only that server's contribution, preserving sibling diagnostics. The TUI underlines annotated text, colors affected gutter numbers by severity, and displays the primary caret's diagnostic message without replacing search/selection backgrounds. @@ -59,7 +60,7 @@ Redraws stage Ratatui's buffer diff, cursor placement, and DEC synchronized-upda `vendor/crossterm` retains Crossterm 0.29.0's upstream source and MIT license, with a small Unix decoder patch exposing `Event::ColorSchemeChanged` for `CSI ? 997 ; 1/2 n`. The report signals a palette refresh, not a choice of hard-coded light/dark colors. Valid fragmented reports and malformed color reports leave following keys intact. Cargo patches both direct and Ratatui dependency paths to this one copy; remove the patch/workspace member once upstream supports these notifications. -The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, optional one-row top and bottom bars, and a separate prompt/message area. Both bars derive visibility from their configured content before allocating rows: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both bars render across ordinary, split, diff, and selector views. Each bar renders configured ordered left/center/right groups, with center anchored to the full row rather than the remaining gap. Overflow keeps the active tab title visible wherever tabs appear. Padding is inside segment styling, while leading/trailing edge glyphs are separate terminal-cell-width decorations. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. An active command/search prompt takes priority in minimal height; zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned. +The TUI allocates a flexible buffer viewport and optional one-row top/bottom bars, with no reserved prompt/message footer. Both bars derive visibility from configured content: empty content takes no row, and tabs-only content is hidden with a single tab in either position. Both render across ordinary, split, diff, and selector views. Ordered left/center/right groups use full-row centering, active-tab collision priority, internal padding, and independent styled edges. Floating Command/Search input owns the hardware cursor; non-focusable operation/diagnostic notifications anchor bottom-right inside the chrome-free workspace and hide obscured document carets. Higher-priority modal overlays suppress them without changing input state. Wheel hit regions clear each frame and consume reports over these popups. Zero-sized terminals render nothing; minimal-height input never reserves another row. Layout and terminal-cell text mapping remain frontend-owned. Mouse wheel input uses the last rendered document/panel rectangles for hit-testing. Document offsets and explorer scroll state remain frontend-owned; three-row wheel steps never become document cursor motions. Manual document views persist until logical caret-reveal, caret/selection, mode, revision, or buffer changes; offscreen primary and browsing-selection cursors are not placed. Hover wheel input reuses the existing editor-owned popup scrolling. diff --git a/docs/configuration.md b/docs/configuration.md index dd6d45c..c7076c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -50,13 +50,13 @@ Plugin paths resolve relative to the config file, not the shell's working direct Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported. -Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count, :language]` and `right: [:position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: +Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count]` and `right: [:language, :lsp, :position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below: ```ruby Vamp.configure do |config| config.bar.top = { - left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer], - center: [], right: [:position], + left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], + center: [], right: [:language, :lsp, :position], mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" }, position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" } } @@ -71,12 +71,16 @@ Vamp.configure do |config| end ``` -Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. +Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:lsp`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, ready LSP server names, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. -A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. The command/search prompt and message area remain separate from both bars. +A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. Commands and buffer searches use floating input; operation feedback uses a bottom-right popup. Neither reserves a footer row. Each segment's `title` is a literal template supporting only `{index}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). Tabs use each tab's buffer metadata; other segments use the active buffer. Tabs default to `"{index} {name}{modified}"`; an empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback. +`:path` and `{path}` abbreviate the actual home-directory prefix to `~/` (`~` for the home directory itself). This is display-only: save paths, buffer identity, and LSP file URIs retain the original path. Sibling directories with similar names are not abbreviated. + +`:lsp` displays only ready servers assigned to the focused buffer, in configured stack order joined with ` + `. Disabled, initializing, failed, or unassigned servers do not appear. Names come from the initialization reply's `serverInfo.name`, falling back to the configured executable basename; no arbitrary named label can make an inactive server appear active. + `padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▒▓"` / `"▓▒░"` edges; mode has only the trailing `"▓▒░"` edge, position and battery only the leading `"░▒▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration. Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar. @@ -170,7 +174,7 @@ Vamp.configure do |config| end ``` -The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous sequence prefixes, or unknown command names leave that binding unchanged and appear in the status message; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and sequence bindings, without restoring any default keys they replaced. +The last valid declaration for a key/mode wins. Invalid keys, incompatible action/mode pairs, ambiguous sequence prefixes, or unknown command names leave that binding unchanged and appear in the operation notification; other valid bindings still load. An invalid leader key reports a notice and retains Space, while valid bindings still install. Ruby syntax errors, unknown methods, and type errors—including non-string or empty leader values—disable the entire configuration and fall back to defaults. One plugin's load failure does not prevent other plugins loading. Disabling a plugin removes its single-key and sequence bindings, without restoring any default keys they replaced. Bindings are resolved inside the editor module, not the terminal adapter. Bracketed paste remains literal and bypasses keybindings. If configuration disables your keys for leaving Insert/Command/Search/Visual mode or entering commands, restart with `--no-config` to restore defaults. @@ -286,7 +290,7 @@ For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Actio Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`). The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments. -Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. +Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic can also appear in the bottom-right notification popup. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. Insert mode hides all diagnostic gutter colors, underlines, counts, and caret messages. Incoming results are still stored; leaving Insert reveals only the latest results matching the current text revision. Editing invalidates earlier results, so stale annotations never reappear on exit. Document synchronization stays live for completion and semantic highlighting; this display policy does not stop server-side diagnostic analysis. diff --git a/docs/development.md b/docs/development.md index b46aad8..d0a0683 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,6 +43,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [` - Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. - For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks. - Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values. +- Status redesign checks cover component-aware home abbreviation without save-path changes; floating command/search submit/cancel, Unicode/tiny/offset clipping and wheel isolation; footer-row reclamation; notification Escape/modal/prefix priority and covered-caret visibility; and ready-server metadata using real framed subprocess fixtures, including stacks, initialization gates, failure, and focus changes. - Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation. - Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed. - Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary. diff --git a/docs/editing.md b/docs/editing.md index 92d561f..58e5bc0 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -142,6 +142,10 @@ Submitting an empty search repeats the last query. Esc cancels without moving th ## Commands, saving, and pasted input +`:` opens compact floating command input; `/` opens the same kind of input for buffer text search. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Input is bounded by the workspace, above configured bars, and temporarily hides operation notifications; there is no permanent prompt/footer row. + +The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar. + Command-capable core actions derive canonical Ex names by removing `core.`: `core.quit` gives `:quit`, `core.save` gives `:save`, and `core.diff.close` gives `:diff.close`. Native editing and Insert-only completion/snippet actions remain action bindings, not additional Ex commands. Commands: `:save [path]`, `:quit`, `:force_quit`, `:save_and_quit [path]`, `:plugins`, and `:plugin-command [arguments]`. Short names remain aliases of these canonical commands, with identical argument and safety rules: @@ -220,9 +224,9 @@ Use `:tab.new` to open the current buffer in a new, focused tab with one pane. E By default, multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. The active tab has an accent-colored background fading into the terminal background through `░▒▓` / `▓▒░`, except the first tab has no left fade; inactive tabs remain unfilled. Narrow terminals keep the active title visible. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`. -The [top and bottom bars](configuration.md#top-and-bottom-bars) accept the same builtin and custom segments, anchored left, truly centered on the full row, or anchored right. Either bar can show tabs or status information, with independent title templates, internal padding, and styled edge glyphs. Defaults keep tabs above and status below; the command/search prompt stays separate. Empty content consumes no row, and a bar whose only visible content is tabs is hidden with a single tab in either position. Custom named values such as a battery label are session-wide and can update through plugins without editing buffers; empty values hide their segments. Both bars share collision handling: narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. +The [top and bottom bars](configuration.md#top-and-bottom-bars) accept the same builtin and custom segments, anchored left, truly centered on the full row, or anchored right. Either bar can show tabs or status information, with independent title templates, internal padding, and styled edge glyphs. Defaults keep tabs and battery above, with home-abbreviated paths on the bottom left and language/ready LSP names/position on the bottom right. Floating input and operation notifications overlay the workspace without reserving a footer. Empty content consumes no row, and a bar whose only visible content is tabs is hidden with a single tab in either position. Custom named values are session-wide and can update through plugins without editing buffers; empty values hide their segments. Both bars share collision handling: narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. -At very small heights, both bars yield to the separate active prompt and a document row. If only one bar row fits while both have content, the top bar receives it. +At very small heights, both bars yield to a document row; floating input still accepts text on a one-row terminal. If only one bar row fits while both have content, the top bar receives it. Closing a tab refuses dirty buffers whose final displayed owner would disappear, including a comparison's working and return buffers. Buffers displayed in another tab remain open with their edits, even on force-close. Other hidden buffers remain loaded. Closing the last tab quits only if every open buffer is clean; force-close quits without saving. diff --git a/examples/config.rb b/examples/config.rb index 956de1b..0618259 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -9,8 +9,8 @@ Vamp.configure do |config| config.file_picker_view = "tree" # Status above, tabs and live battery below; either bar accepts all segments. config.bar.top = { - left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer], - center: [], right: [:position], + left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], + center: [], right: [:language, :lsp, :position], mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" }, position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" } } diff --git a/src/chrome.rs b/src/chrome.rs index 774a2fe..54c1160 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -10,6 +10,7 @@ const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[ "selections", "count", "language", + "lsp", "position", ]; @@ -57,12 +58,12 @@ impl ChromeLine { pub fn bottom() -> Self { Self { - left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 1] + left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 3] .iter() .map(|name| (*name).into()) .collect(), center: Vec::new(), - right: vec!["position".into()], + right: vec!["language".into(), "lsp".into(), "position".into()], segments: DEFAULT_BOTTOM_SEGMENTS .iter() .map(|name| ((*name).into(), default_segment(name))) @@ -267,6 +268,27 @@ mod tests { bottom: ChromeLine, } + #[test] + fn bottom_keeps_metadata_on_the_right_without_filled_styles() { + let line = ChromeLine::bottom(); + assert_eq!( + line.left, + [ + "mode", + "pending", + "buffer", + "diagnostics", + "path", + "selections", + "count" + ] + ); + assert_eq!(line.right, ["language", "lsp", "position"]); + for name in ["language", "lsp"] { + assert_eq!(line.segment(name), &SegmentConfig::default()); + } + } + #[test] fn omitted_options_inherit_and_explicit_groups_replace_defaults() { let lines: Lines = serde_json::from_value(serde_json::json!({ @@ -277,7 +299,7 @@ mod tests { assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓"); assert_eq!(lines.top.segment("battery").padding, 0); assert!(lines.bottom.left.is_empty()); - assert_eq!(lines.bottom.right, ["position"]); + assert_eq!(lines.bottom.right, ["language", "lsp", "position"]); assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default()); } @@ -293,6 +315,7 @@ mod tests { "selections", "count", "language", + "lsp", "position", "battery", "workspace", diff --git a/src/editor.rs b/src/editor.rs index 94c1e5a..1f69030 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1351,11 +1351,14 @@ pub struct Editor { owned_system_clipboard: Option<(String, Vec, bool)>, last_edit: Option, message: String, + notification_visible: bool, top_bar: ChromeLine, bottom_bar: ChromeLine, labels: BTreeMap, battery_charge: Option, battery_label: String, + lsp_names: Vec, + lsp_label: String, quit: bool, capabilities: Vec, features: HashMap, @@ -1441,11 +1444,14 @@ impl Editor { owned_system_clipboard: None, last_edit: None, message: String::new(), + notification_visible: true, top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), labels: BTreeMap::new(), battery_charge: None, battery_label: String::new(), + lsp_names: Vec::new(), + lsp_label: String::new(), quit: false, capabilities, features: HashMap::new(), @@ -1773,6 +1779,7 @@ impl Editor { self.line_pending.take(), self.seek_pending.take(), std::mem::take(&mut self.message), + std::mem::replace(&mut self.notification_visible, true), ) }); self.select_tab(target_tab); @@ -1794,6 +1801,7 @@ impl Editor { self.line_pending, self.seek_pending, self.message, + self.notification_visible, ) = interaction; } match result { @@ -3199,7 +3207,6 @@ impl Editor { popup: Completion { items, selected: 1 }, original, }); - self.message.clear(); Ok(()) } @@ -3835,6 +3842,9 @@ impl Editor { pub fn message(&self) -> &str { &self.message } + pub fn notification_visible(&self) -> bool { + self.notification_visible + } pub fn top_bar_config(&self) -> &ChromeLine { &self.top_bar } @@ -3847,6 +3857,12 @@ impl Editor { pub fn battery_label(&self) -> &str { &self.battery_label } + pub fn lsp_names(&self) -> &[String] { + &self.lsp_names + } + pub fn lsp_label(&self) -> &str { + &self.lsp_label + } pub fn path(&self) -> Option<&Path> { self.active.path.as_deref() } @@ -3884,6 +3900,7 @@ impl Editor { } else { message }; + self.notification_visible = true; } pub fn set_chrome_config( @@ -3928,6 +3945,20 @@ impl Editor { Ok(true) } + pub(crate) fn set_lsp_names(&mut self, mut names: Vec) -> bool { + for name in &mut names { + if name.chars().any(char::is_control) { + *name = sanitize_label(std::mem::take(name)); + } + } + if self.lsp_names == names { + return false; + } + self.lsp_label = names.join(" + "); + self.lsp_names = names; + true + } + pub(crate) fn set_battery_charge(&mut self, charge: Option) -> bool { let charge = charge.filter(|charge| *charge <= 100); if self.battery_charge == charge { @@ -4775,6 +4806,14 @@ impl Editor { if input == Input::Escape && self.count.take().is_some() { return None; } + if input == Input::Escape + && self.active.view.mode == Mode::Normal + && self.notification_visible + && (!self.message.is_empty() || self.diagnostic_at_cursor().is_some()) + { + self.notification_visible = false; + return None; + } if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { @@ -5124,7 +5163,6 @@ impl Editor { self.active.view.linewise = linewise; self.active.view.mode = Mode::Visual; self.normalize_targets(); - self.message.clear(); } } Action::SelectLines => self.split_rows(false, false), @@ -5255,7 +5293,6 @@ impl Editor { } self.active.view.visual_motions = None; self.normalize_targets(); - self.message.clear(); self.record_jump(source); } @@ -5360,21 +5397,12 @@ impl Editor { self.parked.insert(previous.id, previous); self.load_view(); self.reveal_caret(); - self.show_buffer(); - } - - fn show_buffer(&mut self) { - let path = self - .path() - .map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy); - self.set_message(format!("Buffer {}: {path}", self.active.id)); } fn switch_buffer(&mut self, id: usize) { if id == self.active.id { self.reveal_caret(); self.park_source(); - self.show_buffer(); } else if let Some(buffer) = self.parked.remove(&id) { self.activate(buffer); } else { @@ -6424,7 +6452,6 @@ impl Editor { } self.active.view.visual_motions = None; self.normalize_targets(); - self.message.clear(); } fn line_selections(&mut self, count: usize) -> Vec { @@ -6489,7 +6516,6 @@ impl Editor { }, ); } - self.message.clear(); self.reveal_caret(); } @@ -6518,7 +6544,6 @@ impl Editor { if changing { self.start_insert(before.unwrap(), recipe.unwrap().start); self.edit_ranges(action, &ranges); - self.message.clear(); return; } self.edit_ranges(action, &ranges); @@ -6770,7 +6795,6 @@ impl Editor { }); } self.record(before); - self.message.clear(); } if !system { self.clipboard = fragments; @@ -7079,7 +7103,6 @@ impl Editor { } self.active.view.visual_motions = None; self.record(before); - self.message.clear(); } Err(error) => { if !restored { @@ -8612,6 +8635,206 @@ mod tests { } } + #[test] + fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + assert!(editor.notification_visible()); + editor.set_message("Written"); + editor.handle(Input::Right); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.message(), "Written"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, 1); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + editor.set_message("Written"); + assert!(editor.notification_visible()); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + assert_eq!(editor.message(), "Written"); + editor.handle(Input::Char('v')); + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + for prompt in [':', '/'] { + editor.handle(Input::Char(prompt)); + editor.handle(Input::Char('x')); + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert!(editor.notification_visible()); + } + } + + #[test] + fn notification_escape_waits_for_prefixes_and_focused_overlays() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.set_message("Written"); + let targets = editor.active.view.targets.clone(); + for prefix in ["f", "3g", "2d", "4", " b"] { + type_text(&mut editor, prefix); + editor.handle(Input::Escape); + assert!(editor.notification_visible(), "{prefix}"); + assert_eq!(editor.active.view.targets, targets, "{prefix}"); + assert_eq!(editor.count(), None); + } + editor.show_startup_diagnostic("startup".into()); + editor.handle(Input::Escape); + assert!(editor.startup_diagnostic().is_none()); + assert!(editor.notification_visible()); + editor.show_hover("hover".into()).unwrap(); + editor.handle(Input::Escape); + assert!(editor.hover().is_none()); + assert!(editor.notification_visible()); + editor.handle(Input::Ctrl('p')); + editor.handle(Input::Escape); + assert!(editor.palette().is_none()); + assert!(editor.notification_visible()); + editor.open_file_picker(FilePresentation::Docked); + editor.handle(Input::Char('i')); + assert!(editor.file_picker().unwrap().searching); + editor.handle(Input::Escape); + assert!(editor.file_picker().unwrap().focused); + assert!(editor.notification_visible()); + editor.handle(Input::Escape); + assert!(!editor.file_picker().unwrap().focused); + assert!(editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + } + + #[test] + fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcd\nabcd".into())]) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + assert!(editor.notification_visible()); + editor + .replace_diagnostics( + "fixture", + editor.buffer_id(), + editor.text_revision(), + vec![annotation((1, 0), (1, 3), "diagnostic")], + ) + .unwrap(); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "diagnostic"); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + } + + #[test] + fn hidden_pane_interactions_cannot_reopen_or_dismiss_focused_notifications() { + let mut editor = editor(); + let hidden = editor.pane_id(); + editor.execute("pane.split_vertical"); + editor.set_message("focused"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + editor.with_pane(hidden, |pane| pane.set_message("background")); + assert_eq!(editor.message(), "focused"); + assert!(!editor.notification_visible()); + editor.with_render_pane(hidden, |pane| { + assert!(pane.message().is_empty()); + }); + assert!(!editor.notification_visible()); + editor.set_message("focused"); + editor.with_pane(hidden, |pane| { + pane.set_message("background"); + pane.handle(Input::Escape); + }); + assert!(editor.notification_visible()); + assert_eq!(editor.message(), "focused"); + editor.execute("tab.new"); + editor.set_message("new tab"); + editor.handle(Input::Escape); + editor.with_pane(hidden, |pane| pane.set_message("hidden tab")); + assert_eq!(editor.message(), "new tab"); + assert!(!editor.notification_visible()); + } + + #[test] + fn buffer_activation_preserves_last_operation_and_dismissal() { + let directory = Directory::new(); + let path = directory.path("opened.txt"); + fs::write(&path, "text").unwrap(); + let mut editor = editor(); + editor.set_message("Written"); + editor.execute(&format!("edit {}", path.display())); + assert_eq!(editor.message(), "Written"); + editor.handle(Input::Escape); + for command in ["buffer 1", "bnext", "bprev", "buffer 1"] { + editor.execute(command); + assert_eq!(editor.message(), "Written"); + assert!(!editor.notification_visible()); + } + editor.execute("buffer 999"); + assert!(editor.message().contains("No buffer")); + assert!(editor.notification_visible()); + } + + #[test] + fn native_lsp_cache_is_independent_presentation_metadata() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("document".into())]) + .unwrap(); + type_text(&mut editor, "vl"); + editor.set_message("Written"); + editor.notification_visible = false; + editor + .set_label("lsp".into(), "configured label".into()) + .unwrap(); + let targets = editor.active.view.targets.clone(); + let revision = editor.text_revision(); + let history = (editor.active.undo.len(), editor.active.redo.len()); + assert!(editor.lsp_names().is_empty()); + assert_eq!(editor.lsp_label(), ""); + assert!(editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); + assert_eq!(editor.lsp_names(), ["rust-analyzer", "界 server"]); + assert_eq!(editor.lsp_label(), "rust-analyzer + 界 server"); + assert!(!editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()])); + assert!(editor.set_lsp_names(vec!["unsafe\nname".into()])); + assert_eq!(editor.lsp_label(), "unsafe�name"); + assert!(editor.set_lsp_names(Vec::new())); + assert_eq!(editor.lsp_label(), ""); + assert_eq!(editor.label("lsp"), "configured label"); + assert_eq!(editor.message(), "Written"); + assert!(!editor.notification_visible()); + assert_eq!(editor.mode(), Mode::Visual); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.text_revision(), revision); + assert_eq!( + (editor.active.undo.len(), editor.active.redo.len()), + history + ); + } + #[test] fn pane_focus_remaps_do_not_steal_printable_picker_filter_input() { let directory = Directory::new(); @@ -9571,6 +9794,38 @@ mod tests { assert!(editor.diff_interacting()); } + #[test] + fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() { + let directory = Directory::new(); + let mut editor = editor(); + historical_diff( + &mut editor, + &directory.0, + vec!["before".into(), "second".into()], + vec!["after".into(), "second".into()], + ); + set_carets(&mut editor, &[(0, 1), (1, 1)], 1); + editor.set_message("operation"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + assert!(editor.diff_interacting()); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); + editor.set_message("operation"); + editor.diff.as_mut().unwrap().focus = Focus::Files; + editor.handle(Input::Escape); + assert!(editor.notification_visible()); + assert_ne!(editor.diff().unwrap().focus, Focus::Files); + editor.execute("diff.close"); + editor.execute("diff"); + editor.handle(Input::Char('q')); + assert_eq!(editor.diff().unwrap().query, "q"); + editor.handle(Input::Escape); + assert_eq!(editor.diff().unwrap().query, ""); + assert!(editor.notification_visible()); + } + #[test] fn readonly_native_panes_allow_selection_yank_search_and_multicarets() { for focus in [Focus::Before, Focus::After] { @@ -11451,6 +11706,7 @@ mod tests { ); editor.handle(Input::Escape); assert!(editor.hover().is_none()); + assert!(editor.notification_visible()); assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.context().text, text); @@ -11461,6 +11717,9 @@ mod tests { ); assert_eq!(editor.mode(), Mode::Normal); editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.active.view.targets, targets); + editor.handle(Input::Escape); assert_eq!(editor.selection_count(), 1); editor.show_hover("documentation".into()).unwrap(); editor.handle(Input::Char('i')); @@ -14211,6 +14470,11 @@ mod tests { assert_eq!(editor.count(), None); assert_eq!(editor.sequence_pending(), None); editor.handle(Input::Escape); + if !visual { + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + } assert_eq!(editor.mode(), Mode::Normal); assert_eq!(editor.selection_count(), if visual { 2 } else { 1 }); } @@ -16901,6 +17165,10 @@ mod tests { assert_eq!(editor.context().text, "ab\nxyzq"); editor.handle(Input::Char('u')); editor.handle(Input::Escape); + assert!(!editor.notification_visible()); + assert_eq!(editor.selection_count(), 2); + editor.handle(Input::Escape); + assert_eq!(editor.selection_count(), 1); let before = editor.snapshot(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); @@ -17979,6 +18247,8 @@ mod tests { fn plugin_effects_validate_before_any_mutation_and_group_undo() { let mut editor = editor(); editor.set_message("unchanged"); + editor.handle(Input::Escape); + assert!(!editor.notification_visible()); editor .set_label("battery".into(), "unchanged".into()) .unwrap(); @@ -17996,6 +18266,7 @@ mod tests { ); assert_eq!(editor.context().text, ""); assert_eq!(editor.message(), "unchanged"); + assert!(!editor.notification_visible()); assert_eq!(editor.label("battery"), "unchanged"); assert!(!editor.dirty()); assert!( @@ -18024,6 +18295,7 @@ mod tests { .unwrap(); assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.message(), "ok"); + assert!(editor.notification_visible()); editor.handle(Input::Char('u')); assert_eq!(editor.context().text, ""); assert_eq!(editor.label("battery"), "updated"); diff --git a/src/lsp.rs b/src/lsp.rs index 1a78629..df1b79e 100644 --- a/src/lsp.rs +++ b/src/lsp.rs @@ -60,6 +60,7 @@ struct Contribution { } enum Output { + Ready(String), Update(PluginUpdate), Diagnostics { snapshot: Arc, @@ -77,6 +78,7 @@ struct Worker { failure: Receiver, cancelled: Arc, retired: bool, + name: Option, thread: Option>, } @@ -145,6 +147,20 @@ impl Lsp { !self.configs.is_empty() } + /// Ready servers assigned to this document, in configured stack order. + pub fn active_server_names(&self, buffer_id: usize) -> impl Iterator { + self.documents + .get(&buffer_id) + .into_iter() + .flat_map(|doc| &doc.servers) + .filter_map(|key| { + self.servers + .get(key) + .filter(|worker| !worker.retired) + .and_then(|worker| worker.name.as_deref()) + }) + } + pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> { if !self.is_enabled() { return Ok(()); @@ -334,6 +350,7 @@ impl Lsp { break; }; match output { + Output::Ready(name) => worker.name = Some(name), Output::Update(update) => self.updates.push(update), Output::Diagnostics { snapshot, items } => { let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| { @@ -485,6 +502,7 @@ impl Lsp { failure, cancelled, retired: false, + name: None, thread, }, ); @@ -552,6 +570,7 @@ impl Lsp { return; } worker.retired = true; + worker.name = None; worker.cancelled.store(true, Ordering::Release); for (id, doc) in documents { if doc.servers.first() == Some(key) { @@ -1232,6 +1251,18 @@ impl Runtime { Some(json!({"settings": self.config.settings})), )?; self.ready = true; + let name = result["serverInfo"]["name"] + .as_str() + .filter(|name| !name.chars().any(char::is_control)) + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| { + Path::new(&self.config.command[0]) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(&self.config.command[0]) + }); + self.send(Output::Ready(clean(name)))?; let documents = std::mem::take(&mut self.documents); for (id, mut doc) in documents { self.open(&mut doc)?; diff --git a/src/ruby.rs b/src/ruby.rs index 0516a90..571f5bf 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1220,7 +1220,6 @@ end &crate::chrome::SegmentConfig::default() ); assert!(loaded.bottom_bar.left.is_empty()); - assert_eq!(loaded.bottom_bar.right, ["position"]); assert_eq!(loaded.labels["battery"], "bat 38%"); assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}"); } diff --git a/src/session.rs b/src/session.rs index 4c09f20..ac9335e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -251,9 +251,7 @@ impl EditorSession { )); } } - if notices.is_empty() { - editor.set_message("Ready • --no-config uses default keybindings"); - } else { + if !notices.is_empty() { editor.set_message(notices.join(" | ")); } let mut session = Self { @@ -512,7 +510,7 @@ impl EditorSession { } if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) { self.documents.clear(); - return false; + return self.refresh_lsp_names(); } let mut events = Vec::new(); if !self.editor.should_quit() { @@ -604,7 +602,25 @@ impl EditorSession { for (owner, error) in failures { self.retire(&owner, &error); } - changed + changed | self.refresh_lsp_names() + } + + fn refresh_lsp_names(&mut self) -> bool { + let buffer_id = self.editor.buffer_id(); + if self.lsp.active_server_names(buffer_id).eq(self + .editor + .lsp_names() + .iter() + .map(String::as_str)) + { + return false; + } + self.editor.set_lsp_names( + self.lsp + .active_server_names(buffer_id) + .map(str::to_owned) + .collect(), + ) } fn source_is_current(&self, source: InvocationSource) -> bool { diff --git a/src/tui.rs b/src/tui.rs index ad328bd..173e166 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -4,7 +4,7 @@ use std::{ fmt::Write as _, io::{self, Stdout}, panic::{self, AssertUnwindSafe, PanicHookInfo}, - sync::{Arc, Mutex}, + sync::{Arc, LazyLock, Mutex}, time::{Duration, Instant}, }; @@ -407,7 +407,7 @@ impl BufferViewports { .retain(|id, _| editor.pane_buffer_id(*id).is_some()); self.syntax.retain(|id, _| editor.has_buffer(*id)); let syntax = self.syntax.entry(editor.buffer_id()).or_default(); - let [full_workspace, prompt] = draw_bars(frame, editor, frame.area(), syntax); + let full_workspace = draw_bars(frame, editor, frame.area(), syntax); let multiple = editor.pane_layout().len() > 1; let diff_header = editor .diff() @@ -446,10 +446,6 @@ impl BufferViewports { .visit_rects(pane_rect(workspace), |id, area| { self.rects.push((id, terminal_rect(area))); }); - // An active prompt still needs its focused render pass when no document row fits. - if self.rects.is_empty() && prompt.height > 0 { - self.rects.push((focused, workspace)); - } // Paint focused overlays last, so an adjacent leaf cannot erase them. for active_pass in [false, true] { for index in 0..self.rects.len() { @@ -472,11 +468,6 @@ impl BufferViewports { frame.render_widget(block, leaf); leaf = inner; } - let prompt = if id == focused { - prompt - } else { - Rect::default() - }; editor.with_render_pane(id, |view| { self.activate_document(view.buffer_id()); self.documents.retain(|id, _| view.has_buffer(*id)); @@ -489,7 +480,7 @@ impl BufferViewports { draw_document( frame, view, - [leaf, prompt], + leaf, full_workspace, top, left, @@ -509,6 +500,10 @@ impl BufferViewports { } self.activate(focused, editor); self.panels.workspace_managed = false; + if self.rects.is_empty() { + self.panels.begin_frame(); + self.document.geometry = None; + } if let Some(header) = diff_header && let Some(diff) = editor.diff() { @@ -1030,6 +1025,33 @@ impl ChromeItem<'_> { } } +fn display_path(path: &std::path::Path) -> Cow<'_, str> { + static HOME: LazyLock> = LazyLock::new(|| { + std::env::var_os("HOME") + .filter(|home| !home.is_empty()) + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from) + .filter(|home| { + home.is_absolute() + && home + .to_str() + .is_some_and(|home| !home.chars().any(char::is_control)) + }) + }); + abbreviate_home(path, HOME.as_deref()) +} + +fn abbreviate_home<'a>(path: &'a std::path::Path, home: Option<&std::path::Path>) -> Cow<'a, str> { + if let Some(suffix) = home.and_then(|home| path.strip_prefix(home).ok()) { + return if suffix.as_os_str().is_empty() { + Cow::Borrowed("~") + } else { + Cow::Owned(format!("~/{}", suffix.display())) + }; + } + path.to_string_lossy() +} + fn chrome_title( template: &str, index: usize, @@ -1043,9 +1065,7 @@ fn chrome_title( .to_string_lossy() }) .unwrap_or(Cow::Borrowed("[No Name]")); - let path = path - .map(|path| path.to_string_lossy()) - .unwrap_or(Cow::Borrowed("[No Name]")); + let path = path.map(display_path).unwrap_or(Cow::Borrowed("[No Name]")); let mut title = String::with_capacity(template.len() + name.len()); let mut rest = template; while let Some((literal, placeholder)) = rest.split_once('{') { @@ -1124,6 +1144,7 @@ fn chrome_items<'a>( } else { syntax.language().unwrap_or_default().to_owned() }), + "lsp" => Cow::Borrowed(editor.lsp_label()), "diagnostics" => { let mut counts = [0; 4]; for diagnostic in editor.diagnostics() { @@ -1362,41 +1383,27 @@ fn chrome_visible(editor: &Editor, groups: &[Vec>; 3]) -> bool { || (editor.tab_count() > 1 && groups.iter().any(|group| !group.is_empty())) } -fn document_layout(area: Rect, editor: &Editor, visible: [bool; 2]) -> [Rect; 4] { - let prompting = matches!(editor.mode(), Mode::Command | Mode::Search); - let dock_focused = editor - .file_picker() - .is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked); - let prompt = - u16::from(editor.diff().is_none() && (area.height >= 2 || (prompting && !dock_focused))) - .min(area.height); - // Both bars yield to the separate prompt and the last document row. - let available = area.height.saturating_sub(prompt + 1); +fn document_layout(area: Rect, visible: [bool; 2]) -> [Rect; 3] { + // Bars yield to the last document row, identically at either edge. + let available = area.height.saturating_sub(1); let top = u16::from(visible[0]).min(available); let bottom = u16::from(visible[1]).min(available - top); let workspace = Rect::new( area.x, area.y.saturating_add(top), area.width, - area.height - top - bottom - prompt, + area.height - top - bottom, ); [ Rect::new(area.x, area.y, area.width, top), workspace, Rect::new(area.x, workspace.bottom(), area.width, bottom), - Rect::new(area.x, area.bottom() - prompt, area.width, prompt), ] } -fn draw_bars( - frame: &mut Frame, - editor: &Editor, - area: Rect, - syntax: &mut SyntaxCache, -) -> [Rect; 2] { +fn draw_bars(frame: &mut Frame, editor: &Editor, area: Rect, syntax: &mut SyntaxCache) -> Rect { if area.width == 0 || area.height == 0 { - let [_, workspace, _, prompt] = document_layout(area, editor, [false; 2]); - return [workspace, prompt]; + return area; } // Resolve the active buffer's language before computing either bar's visibility. syntax.update_visible(editor.lines(), editor.path(), editor.text_revision(), &[]); @@ -1407,11 +1414,11 @@ fn draw_bars( let visible = groups .each_ref() .map(|groups| chrome_visible(editor, groups)); - let [top, workspace, bottom, prompt] = document_layout(area, editor, visible); + let [top, workspace, bottom] = document_layout(area, visible); for (groups, area) in groups.into_iter().zip([top, bottom]) { draw_chrome(frame, groups, area); } - [workspace, prompt] + workspace } #[cfg(test)] @@ -1425,9 +1432,9 @@ fn draw( panels: &mut WheelPanels, ) { let area = frame.area(); - let areas = draw_bars(frame, editor, area, syntax); + let workspace = draw_bars(frame, editor, area, syntax); draw_document( - frame, editor, areas, areas[0], top, left, syntax, document, panels, + frame, editor, workspace, workspace, top, left, syntax, document, panels, ); } @@ -1435,7 +1442,7 @@ fn draw( fn draw_document( frame: &mut Frame, editor: &Editor, - [workspace, prompt_area]: [Rect; 2], + workspace: Rect, overlay_area: Rect, top: &mut usize, left: &mut usize, @@ -1463,10 +1470,9 @@ fn draw_document( return; } panels.diff_syntax = None; - if overlay_area.width == 0 || (overlay_area.height == 0 && prompt_area.height == 0) { + if overlay_area.width == 0 || overlay_area.height == 0 { return; } - let prompting = matches!(editor.mode(), Mode::Command | Mode::Search); let (viewport, dock) = match editor.file_picker() { Some(picker) if !panels.workspace_managed @@ -1776,72 +1782,35 @@ fn draw_document( ); } } - if prompting && prompt_area.height > 0 && prompt_area.width > 0 { - let prefix = if editor.mode() == Mode::Search { - '/' - } else { - ':' - }; - let command = format!("{prefix}{}", editor.command_line()); - let command_width = display_column(&command, usize::MAX); - let prompt_width = usize::from(prompt_area.width); - let offset = command_width.saturating_sub(prompt_width.saturating_sub(1)); - frame.render_widget( - Paragraph::new(visible_line(&command, offset, prompt_width)), - prompt_area, - ); - if focused && !editor.file_picker().is_some_and(|picker| picker.focused) { - frame.set_cursor_position(( - prompt_area.x + (command_width - offset) as u16, - prompt_area.y, - )); - } - } else { - let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { - let (_, _, label) = diagnostic_severity(diagnostic.severity); - let diagnostic = display_text(&diagnostic.message); - Cow::Owned(if editor.message().is_empty() { - format!("{label}: {diagnostic}") - } else { - format!("{} | {label}: {diagnostic}", editor.message()) - }) - } else { - Cow::Borrowed(editor.message()) - }; - frame.render_widget(Paragraph::new(message), prompt_area); - if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { - let column = display_column(&editor.lines()[cursor.row], cursor.column); - let continuation = if wrapped { - caret_segment( - document - .wrap - .segments(cursor.row, &editor.lines()[cursor.row]), - column, - ) - } else { - 0 - }; - if let Some((screen_row, (_, _, segment))) = + if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { + let column = display_column(&editor.lines()[cursor.row], cursor.column); + let continuation = if wrapped { + caret_segment( document - .visible - .iter() - .enumerate() - .find(|(_, (row, part, segment))| { - *row == cursor.row - && *part == continuation - && column >= segment.start - && column < segment.start.saturating_add(width) - }) - { - let position = Position::new( - viewport.x + (column - segment.start) as u16, - viewport.y + screen_row as u16, - ); - if focused { - frame.set_cursor_position(position); - } - document_caret = Some(position); - } + .wrap + .segments(cursor.row, &editor.lines()[cursor.row]), + column, + ) + } else { + 0 + }; + if let Some((screen_row, (_, _, segment))) = + document + .visible + .iter() + .enumerate() + .find(|(_, (row, part, segment))| { + *row == cursor.row + && *part == continuation + && column >= segment.start + && column < segment.start.saturating_add(width) + }) + { + let position = Position::new( + viewport.x + (column - segment.start) as u16, + viewport.y + screen_row as u16, + ); + document_caret = Some(position); } } if !focused { @@ -1865,6 +1834,92 @@ fn draw_document( } } +fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option { + if area.width == 0 || area.height == 0 { + return None; + } + let panel = floating_panel(area); + let width = panel.width.min(72); + let height = panel.height.min(3); + let panel = Rect::new( + panel.x + (panel.width - width) / 2, + panel.y + (panel.height - height) / 2, + width, + height, + ); + let bordered = panel.width >= 4 && panel.height >= 3; + let block = Block::default() + .borders(if bordered { + Borders::ALL + } else { + Borders::NONE + }) + .border_style(Style::default().fg(Color::Cyan)); + let content = block.inner(panel); + frame.render_widget(Clear, panel); + frame.render_widget(block, panel); + let prefix = if editor.mode() == Mode::Search { + '/' + } else { + ':' + }; + let command = format!("{prefix}{}", editor.command_line()); + let column = display_column(&command, usize::MAX); + let width = usize::from(content.width); + let offset = column.saturating_sub(width.saturating_sub(1)); + frame.render_widget( + Paragraph::new(visible_line(&command, offset, width)), + content, + ); + frame.set_cursor_position((content.x + (column - offset) as u16, content.y)); + Some(panel) +} + +fn draw_notification(frame: &mut Frame, editor: &Editor, area: Rect) -> Option { + if !editor.notification_visible() || area.width == 0 || area.height == 0 { + return None; + } + let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { + let (_, _, label) = diagnostic_severity(diagnostic.severity); + let diagnostic = &diagnostic.message; + Cow::Owned(if editor.message().is_empty() { + format!("{label}: {diagnostic}") + } else { + format!("{} | {label}: {diagnostic}", editor.message()) + }) + } else { + Cow::Borrowed(editor.message()) + }; + if message.is_empty() { + return None; + } + let message = if message.chars().any(char::is_control) { + Cow::Owned(display_text(&message)) + } else { + message + }; + let width = (UnicodeWidthStr::width(message.as_ref()).saturating_add(2)) + .min(60) + .min(usize::from(area.width)) as u16; + let bordered = width >= 4 && area.height >= 3; + let border = 2 * u16::from(bordered); + let paragraph = Paragraph::new(message).wrap(Wrap { trim: false }); + let height = (paragraph.line_count(width - border).min(6) as u16 + border).min(area.height); + let panel = Rect::new(area.right() - width, area.bottom() - height, width, height); + let block = Block::default() + .borders(if bordered { + Borders::ALL + } else { + Borders::NONE + }) + .border_style(Style::default().fg(Color::DarkGray)); + let content = block.inner(panel); + frame.render_widget(Clear, panel); + frame.render_widget(block, panel); + frame.render_widget(paragraph, content); + Some(panel) +} + fn draw_overlays( frame: &mut Frame, editor: &Editor, @@ -1872,6 +1927,33 @@ fn draw_overlays( caret: Option<(Rect, Position)>, panels: &mut WheelPanels, ) { + panels.input = None; + panels.notification = None; + let covered = editor.completion().is_some() + || editor.palette().is_some() + || editor.hover().is_some() + || editor.startup_diagnostic().is_some() + || editor + .file_picker() + .is_some_and(|picker| picker.presentation == FilePresentation::Floating); + if !covered { + if matches!(editor.mode(), Mode::Command | Mode::Search) { + panels.input = draw_floating_input(frame, editor, area); + } else if editor.diff().is_none_or(|diff| diff.phase == Phase::View) + && !editor.file_picker().is_some_and(|picker| picker.focused) + { + panels.notification = draw_notification(frame, editor, area); + } + } + if !matches!(editor.mode(), Mode::Command | Mode::Search) + && !editor.file_picker().is_some_and(|picker| picker.focused) + && let Some((_, position)) = caret + && !panels + .notification + .is_some_and(|panel| panel.contains(position)) + { + frame.set_cursor_position(position); + } if let Some(completion) = editor.completion() && let Some((_, caret)) = caret { @@ -2160,12 +2242,6 @@ fn paint_diff_editor( } } } - if let Some(position) = cursor.filter(|_| editor.render_pane_focused()) - && !matches!(editor.mode(), Mode::Command | Mode::Search) - && editor.palette().is_none() - { - frame.set_cursor_position(position); - } cursor } @@ -2179,28 +2255,6 @@ fn draw_diff_popups( if !editor.render_pane_focused() { return; } - if area.width > 0 && area.height > 0 { - let footer = Rect::new(area.x, area.bottom() - 1, area.width, 1); - if matches!(editor.mode(), Mode::Command | Mode::Search) { - let prefix = if editor.mode() == Mode::Search { - '/' - } else { - ':' - }; - let command = format!("{prefix}{}", editor.command_line()); - let column = display_column(&command, usize::MAX); - let offset = column.saturating_sub(usize::from(area.width).saturating_sub(1)); - frame.render_widget(Clear, footer); - frame.render_widget( - Paragraph::new(visible_line(&command, offset, usize::from(area.width))), - footer, - ); - frame.set_cursor_position((footer.x + (column - offset) as u16, footer.y)); - } else if !editor.message().is_empty() { - frame.render_widget(Clear, footer); - frame.render_widget(Paragraph::new(display_text(editor.message())), footer); - } - } panels.caret = caret; if !panels.workspace_managed { draw_overlays(frame, editor, area, caret, panels); @@ -3387,6 +3441,8 @@ impl Default for DiffColors { struct WheelPanels { workspace_managed: bool, caret: Option<(Rect, Position)>, + input: Option, + notification: Option, completion: Option, hover: Option<(Rect, usize)>, hover_syntax: SyntaxCache, @@ -3430,6 +3486,8 @@ impl WheelPanels { } self.hover = None; self.caret = None; + self.input = None; + self.notification = None; self.completion = None; self.files = None; self.previous_diff = self.diff.take(); @@ -3475,6 +3533,13 @@ impl WheelPanels { MouseEventKind::ScrollDown | MouseEventKind::ScrollRight ); let position = Position::new(mouse.column, mouse.row); + if self.input.is_some_and(|area| area.contains(position)) + || self + .notification + .is_some_and(|area| area.contains(position)) + { + return true; + } if horizontal && (session.editor().palette().is_some() || session.editor().startup_diagnostic().is_some() @@ -5464,6 +5529,407 @@ mod tests { .unwrap(); } + #[test] + fn home_abbreviation_matches_components_and_only_changes_display() { + use std::path::Path; + let home = Path::new("/users/λ"); + for (path, expected) in [ + ("/users/λ", "~"), + ("/users/λ/", "~"), + ("/users/λ/src/file.rs", "~/src/file.rs"), + ("/users/λ-other/file.rs", "/users/λ-other/file.rs"), + ("/else/users/λ/file.rs", "/else/users/λ/file.rs"), + ("users/λ/file.rs", "users/λ/file.rs"), + ] { + assert_eq!(abbreviate_home(Path::new(path), Some(home)), expected); + assert_eq!(abbreviate_home(Path::new(path), None), path); + } + let directory = Directory::new(); + let path = directory.file("λ\t{index}.rs", "source"); + let editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + let groups = chrome_groups(&editor, editor.bottom_bar_config(), &SyntaxCache::new()); + let title = chrome_title("{path}", 2, Some(&path), false); + assert_eq!(title, display_text(&display_path(&path))); + assert!(groups.iter().flatten().any(|item| item.text == title)); + assert_eq!(editor.path(), Some(path.as_path())); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "source"); + } + + #[test] + fn native_lsp_names_are_borrowed_in_either_bar_and_ignore_configured_labels() { + let mut editor = Editor::open(None, vec![]).unwrap(); + configure_chrome(&mut editor, |top, bottom| { + for line in [top, bottom] { + line.left = vec!["lsp".into()]; + line.center.clear(); + line.right.clear(); + } + }); + editor + .set_label("lsp".into(), "not a running server".into()) + .unwrap(); + for line in [editor.top_bar_config(), editor.bottom_bar_config()] { + assert!(chrome_groups(&editor, line, &SyntaxCache::new())[0].is_empty()); + } + editor.set_lsp_names(vec!["server one".into(), "server two".into()]); + for line in [editor.top_bar_config(), editor.bottom_bar_config()] { + let groups = chrome_groups(&editor, line, &SyntaxCache::new()); + assert_eq!(groups[0].len(), 1); + assert!(matches!( + groups[0][0].text, + Cow::Borrowed("server one + server two") + )); + } + } + + #[test] + fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { + for context in ["document", "split", "diff", "split-diff", "selector"] { + for prefix in [':', '/'] { + // A selector's slash belongs to its revision filter, not native search. + if context == "selector" && prefix == '/' { + continue; + } + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) + .unwrap(); + if context.starts_with("split") { + editor.handle(Input::Char('|')); + } + if context.ends_with("diff") { + install_readonly_diff(&mut editor, deletion_file()); + editor.handle(Input::Char('\t')); + assert!(editor.diff_interacting()); + } else if context == "selector" { + editor.execute("diff"); + let (id, _) = editor.take_diff_request().unwrap(); + editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); + } + editor.set_message("saved notification"); + editor.handle(Input::Char(prefix)); + let query = format!("{}TAIL", "界e\u{301}👩‍🚀".repeat(30)); + for ch in query.chars() { + editor.handle(Input::Char(ch)); + } + assert!(matches!(editor.mode(), Mode::Command | Mode::Search)); + assert_eq!(editor.command_line(), query); + let before = (editor.lines().to_vec(), editor.cursor(), editor.carets()); + let mut views = BufferViewports::default(); + for (width, height) in [(100, 9), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 16), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_none()); + if width == 0 || height == 0 { + assert!(views.panels.input.is_none()); + assert!(views.document.geometry.is_none()); + continue; + } + let panel = views.panels.input.unwrap(); + let cursor = terminal.get_cursor_position().unwrap(); + assert_eq!(panel.intersection(area), panel); + assert!( + panel.contains(cursor), + "{context} {area:?} {panel:?} {cursor:?}" + ); + assert!(panel.width <= 72 && panel.height <= 3); + assert_eq!( + terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), + " " + ); + if width >= 12 { + assert!(pane_screen(&terminal, panel).contains("TAIL")); + } + assert_eq!(terminal.backend().buffer()[(0, 0)].symbol(), " "); + assert_eq!( + before, + (editor.lines().to_vec(), editor.cursor(), editor.carets()) + ); + } + editor.handle(Input::Escape); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.message(), "saved notification"); + } + } + } + + #[test] + fn cancelling_floating_inputs_restores_obscured_document_cells() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace( + vec!["DOCUMENT".repeat(15); 20].join("\n"), + )]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let baseline = screen_text(&terminal); + let geometry = views.document.geometry; + let caret = views.panels.caret; + for prefix in [':', '/'] { + editor.handle(Input::Char(prefix)); + for ch in "long input 界e\u{301}👩‍🚀".chars() { + editor.handle(Input::Char(ch)); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.input.is_some()); + assert_eq!(views.document.geometry, geometry); + assert_eq!(views.panels.caret, caret); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.input.is_none()); + assert_eq!(screen_text(&terminal), baseline); + assert_eq!(views.document.geometry, geometry); + assert_eq!(views.panels.caret, caret); + } + } + + #[test] + fn notification_popup_persists_blocks_wheels_and_restores_real_document_carets() { + let mut session = EditorSession::open(None, vec![], None).unwrap(); + session.editor_mut().set_message(""); + session + .editor_mut() + .apply(vec![crate::editor::Effect::Replace( + "DOCUMENT\n".repeat(20), + )]) + .unwrap(); + session.handle(Input::Ctrl('e')); + session.handle(Input::Ctrl('e')); + assert_eq!(session.editor().selection_count(), 3); + let area = Rect::new(3, 2, 100, 12); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 18), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let baseline = screen_text(&terminal); + let geometry = views.document.geometry; + let caret = terminal.get_cursor_position().unwrap(); + let carets = session.editor().carets(); + let offsets = views.offsets.clone(); + let pane = session.editor().pane_id(); + session + .editor_mut() + .set_message("write failed: permission denied"); + for _ in 0..3 { + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let popup = views.panels.notification.unwrap(); + assert_eq!(popup.right(), area.right()); + assert_eq!(popup.bottom(), views.rects[0].1.bottom()); + assert!(popup.bottom() < area.bottom()); + assert!(popup.x > geometry.unwrap().1.x); + assert!(pane_screen(&terminal, popup).contains("write failed: permission denied")); + assert_eq!(views.document.geometry, geometry); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + for kind in [ + MouseEventKind::ScrollUp, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollLeft, + MouseEventKind::ScrollRight, + ] { + assert!(views.scroll(&mut session, document_wheel(kind, popup.x, popup.y))); + } + assert_eq!(views.offsets, offsets); + assert_eq!(session.editor().pane_id(), pane); + assert_eq!(session.editor().carets(), carets); + } + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.panels.notification.is_none()); + assert_eq!(session.editor().carets(), carets); + assert_eq!( + session.editor().message(), + "write failed: permission denied" + ); + assert_eq!(screen_text(&terminal), baseline); + session.handle(Input::Escape); + assert_eq!(session.editor().selection_count(), 1); + session.editor_mut().set_message("new operation"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.panels.notification.is_some()); + + for prefix in [':', '/'] { + session.handle(Input::Char(prefix)); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let input = views.panels.input.unwrap(); + assert!(views.panels.notification.is_none()); + let caret = terminal.get_cursor_position().unwrap(); + let mode = session.editor().mode(); + for kind in [ + MouseEventKind::ScrollUp, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollLeft, + MouseEventKind::ScrollRight, + ] { + assert!(views.scroll(&mut session, document_wheel(kind, input.x, input.y))); + } + assert_eq!(views.offsets, offsets); + assert_eq!(session.editor().mode(), mode); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), caret); + session.handle(Input::Escape); + } + } + + #[test] + fn notification_hides_covered_hardware_cursor_without_changing_its_document_geometry() { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor + .apply(vec![crate::editor::Effect::Replace("DOCUMENT".into())]) + .unwrap(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(12, 4)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let caret = views.panels.caret; + let geometry = views.document.geometry; + assert!(terminal.backend().cursor_visible()); + editor.set_message("A long operation notification wraps over the document"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!( + views + .panels + .notification + .unwrap() + .contains(caret.unwrap().1) + ); + assert!(!terminal.backend().cursor_visible()); + assert_eq!(views.panels.caret, caret); + assert_eq!(views.document.geometry, geometry); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(terminal.backend().cursor_visible()); + assert_eq!(terminal.get_cursor_position().unwrap(), caret.unwrap().1); + assert_eq!(views.document.geometry, geometry); + } + + #[test] + fn notifications_yield_to_existing_overlays_and_stay_bounded_when_tiny() { + for overlay in [ + "palette", + "hover", + "completion", + "files", + "startup", + "selector", + ] { + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_message("LAST OPERATION"); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_some()); + match overlay { + "palette" => { + editor.handle(Input::Ctrl('p')); + } + "hover" => { + editor.show_hover("Hover details".into()).unwrap(); + } + "completion" => { + use crate::plugin::{CompletionEdit, CompletionItem, TextPosition}; + editor.handle(Input::Char('i')); + editor + .show_completion(vec![CompletionItem { + label: "completion".into(), + detail: None, + primary_edit: 0, + snippet: None, + edits: vec![CompletionEdit { + start: TextPosition { row: 0, column: 0 }, + end: TextPosition { row: 0, column: 0 }, + text: "completion".into(), + }], + }]) + .unwrap(); + } + "files" => { + editor.handle(Input::Char(' ')); + editor.handle(Input::Char('o')); + } + "startup" => editor.show_startup_diagnostic("Configuration error".into()), + _ => { + editor.execute("diff"); + } + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(views.panels.notification.is_none(), "{overlay}"); + assert!( + !screen_text(&terminal).contains("LAST OPERATION"), + "{overlay}" + ); + } + let mut editor = Editor::open(None, vec![]).unwrap(); + editor.set_message(format!("{}\tEND", "界e\u{301} operation ".repeat(50))); + for (width, height) in [(100, 20), (12, 3), (3, 2), (1, 1), (0, 1), (1, 0)] { + let area = Rect::new(3, 2, width, height); + let mut terminal = Terminal::with_options( + ratatui::backend::TestBackend::new(110, 26), + ratatui::TerminalOptions { + viewport: ratatui::Viewport::Fixed(area), + }, + ) + .unwrap(); + terminal + .draw(|frame| { + let popup = draw_notification(frame, &editor, area); + if width == 0 || height == 0 { + assert!(popup.is_none()); + } else { + let popup = popup.unwrap(); + assert_eq!(popup.intersection(area), popup); + assert_eq!(popup.right(), area.right()); + assert_eq!(popup.bottom(), area.bottom()); + assert!(popup.width <= 60 && popup.height <= 8); + } + }) + .unwrap(); + assert!(!screen_text(&terminal).contains("END")); + } + } + #[test] fn tab_titles_update_templates_without_reinterpreting_file_names() { let directory = Directory::new(); @@ -5792,7 +6258,7 @@ mod tests { } #[test] - fn empty_bar_content_reclaims_both_rows_without_hiding_the_prompt() { + fn empty_bar_content_reclaims_both_rows_even_while_prompting() { let mut editor = Editor::open(None, vec![]).unwrap(); editor.execute("tab.new"); configure_chrome(&mut editor, |top, bottom| { @@ -5815,20 +6281,18 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); terminal .draw(|frame| { - let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace, Rect::new(0, 0, 40, 4)); - assert_eq!(prompt, Rect::new(0, 4, 40, 1)); + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, frame.area()); }) .unwrap(); editor.set_label("empty".into(), "present".into()).unwrap(); terminal .draw(|frame| { - let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace, Rect::new(0, 1, 40, 2)); - assert_eq!(prompt, Rect::new(0, 4, 40, 1)); + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, Rect::new(0, 1, 40, 3)); }) .unwrap(); - for y in [0, 3] { + for y in [0, 4] { let title = pane_screen(&terminal, Rect::new(0, y, 40, 1)); assert!(title.contains("Not visible without a value"), "{title}"); assert!(!title.contains('▓'), "{title}"); @@ -5836,9 +6300,8 @@ mod tests { editor.handle(Input::Char(':')); terminal .draw(|frame| { - let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax); - assert_eq!(workspace.height, 2); - assert_eq!(prompt, Rect::new(0, 4, 40, 1)); + let workspace = draw_bars(frame, &editor, frame.area(), &mut syntax); + assert_eq!(workspace, Rect::new(0, 1, 40, 3)); }) .unwrap(); } @@ -5882,27 +6345,25 @@ mod tests { _ if prompting => "COMMAND", _ => "NORMAL", }; - let visible = height >= 3; + let visible = height >= 2; assert_eq!(screen_text(&terminal).contains(bar_text), visible); let cursor = terminal.get_cursor_position().unwrap(); assert!( area.contains(cursor), "{segment} {at_top} prompting={prompting} {area:?}: {cursor:?}" ); + let viewport = views.document.geometry.unwrap().1; + assert_eq!(viewport.height, height - u16::from(visible)); if prompting { - assert_eq!(cursor.y, area.bottom() - 1); - assert_eq!( - terminal.backend().buffer()[(area.x, cursor.y)].symbol(), - ":", - ); - } - if !prompting || height > 1 { - let viewport = views.document.geometry.unwrap().1; - assert!(viewport.height > 0); + let input = views.panels.input.unwrap(); + assert!(input.contains(cursor)); + assert!(pane_screen(&terminal, input).contains(':')); + } else { + assert!(viewport.contains(cursor)); assert!(pane_screen(&terminal, viewport).contains("DOCUMENT")); } if visible { - let y = if at_top { area.y } else { area.bottom() - 2 }; + let y = if at_top { area.y } else { area.bottom() - 1 }; assert!( pane_screen(&terminal, Rect::new(area.x, y, area.width, 1)) .contains(bar_text) @@ -5923,22 +6384,22 @@ mod tests { terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 11)); + assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, 0),)); editor.execute("tab.new"); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 9)); - for y in [0, 10] { + assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 10)); + for y in [0, 11] { assert!( !views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),) ); } let caret = terminal.get_cursor_position().unwrap(); - assert!(caret.y >= 1 && caret.y < 10); - for y in [0, 10] { + assert!(views.rects[0].1.contains(caret)); + for y in [0, 11] { let bar = pane_screen(&terminal, Rect::new(0, y, 80, 1)); assert!( bar.contains("1 [No Name]") && bar.contains("2 [No Name]"), @@ -5949,9 +6410,9 @@ mod tests { terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); - assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 11)); + assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 12)); assert!(!screen_text(&terminal).contains("[No Name]")); - for y in [0, 10] { + for y in [0, 11] { assert!( views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),) ); @@ -5982,7 +6443,7 @@ mod tests { top.contains(&format!("{}:{}", cursor.row + 1, cursor.column + 1)), "{top}" ); - let bottom = pane_screen(&terminal, Rect::new(0, 10, 240, 1)); + let bottom = pane_screen(&terminal, Rect::new(0, 11, 240, 1)); assert!( bottom.contains("1 active.rs [+]") && bottom.contains("2 active.rs [+]"), "{bottom}" @@ -5994,9 +6455,10 @@ mod tests { .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert_eq!(views.rects[0].1, before); - assert!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)).starts_with(':')); - assert_eq!(terminal.get_cursor_position().unwrap().y, 11); - assert_eq!(pane_screen(&terminal, Rect::new(0, 10, 240, 1)), bottom); + let input = views.panels.input.unwrap(); + assert!(pane_screen(&terminal, input).contains(':')); + assert!(input.contains(terminal.get_cursor_position().unwrap())); + assert_eq!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)), bottom); } #[test] @@ -6021,7 +6483,7 @@ mod tests { let assert_bars = |terminal: &Terminal, views: &BufferViewports| { assert!(pane_screen(terminal, Rect::new(0, 0, 120, 1)).contains("WORKSPACE")); - let bottom_y = if views.panels.diff.is_some() { 29 } else { 28 }; + let bottom_y = 29; assert!(pane_screen(terminal, Rect::new(0, bottom_y, 120, 1)).contains("BOTTOM")); for (_, rect) in &views.rects { assert!(rect.y >= 1 && rect.bottom() <= bottom_y); @@ -6232,7 +6694,7 @@ mod tests { draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); }) .unwrap(); - let y = if at_top { 0 } else { 4 }; + let y = if at_top { 0 } else { 5 }; let row = pane_screen(&terminal, Rect::new(0, y, 48, 1)); assert_eq!(row.trim(), "░▒▓ bat 27%"); let x = [0, 18, 36][anchor]; @@ -6246,10 +6708,8 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap(); terminal .draw(|frame| { - let [workspace, _] = - draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); - assert_eq!(workspace.y, 0); - assert_eq!(workspace.height, 5); + let workspace = draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new()); + assert_eq!(workspace, frame.area()); }) .unwrap(); assert!(!screen_text(&terminal).contains("bat 27%")); @@ -6516,7 +6976,11 @@ mod tests { assert!(views.rects.iter().all(|(_, pane)| pane.right() <= dock.x)); assert_eq!(dock.right(), 120); assert_eq!(dock.y, 0); - assert_eq!(dock.height, 28); + assert_eq!( + Some(dock.bottom()), + views.rects.iter().map(|(_, pane)| pane.bottom()).max() + ); + assert_eq!(dock.height + 1, terminal.backend().buffer().area.height); } #[test] @@ -8107,6 +8571,9 @@ mod tests { for _ in 0..caret { editor.handle(Input::Right); } + if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { + editor.handle(Input::Escape); + } let area = Rect::new(3, 2, width + 2, 4); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(20, 8), @@ -8427,7 +8894,20 @@ end assert_eq!(session.editor().cursor().row, 15); assert_eq!(session.editor().cursor().column, 40); assert_eq!(session.editor().mode(), Mode::Normal); - assert_eq!(viewports.offsets[&target_id], (12, 26)); + if session.editor().notification_visible() && !session.editor().message().is_empty() { + session.handle(Input::Escape); + terminal + .draw(|frame| viewports.draw(frame, session.editor_mut())) + .unwrap(); + } + let text_viewport = viewports.panels.caret.unwrap().0; + let offsets = viewports.offsets[&target_id]; + assert_eq!( + offsets.0, + session.editor().cursor().row + 1 - usize::from(text_viewport.height) + ); + assert!(offsets.1 > 0); + assert!(text_viewport.contains(terminal.get_cursor_position().unwrap())); let cursor = terminal.get_cursor_position().unwrap(); assert_eq!( terminal.backend().buffer()[(cursor.x, cursor.y)].symbol(), @@ -8532,18 +9012,30 @@ end terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2); - assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); + let (viewport, caret) = viewports.panels.caret.unwrap(); + assert!(viewports.offsets[&editor.buffer_id()].0 > 0); + assert!(viewport.contains(caret)); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "v" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + color, + "{name}" + ); editor .apply(vec![crate::editor::Effect::Replace(after.into())]) .unwrap(); terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); assert_eq!( - terminal.backend().buffer()[(2, 0)].fg, + terminal.backend().buffer()[(caret.x, caret.y)].symbol(), + "v" + ); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, Color::Reset, "{name}" ); @@ -8551,7 +9043,11 @@ end terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); + assert_eq!( + terminal.backend().buffer()[(caret.x, caret.y)].fg, + color, + "{name}" + ); } } @@ -8665,6 +9161,9 @@ end for _ in 0..caret { editor.handle(Input::Right); } + if editor.notification_visible() && editor.diagnostic_at_cursor().is_some() { + editor.handle(Input::Escape); + } let area = Rect::new(3, 2, width + 2, 4); let mut terminal = Terminal::with_options( ratatui::backend::TestBackend::new(20, 8), @@ -10284,7 +10783,7 @@ end ) }) .unwrap(); - let workspace = Rect::new(area.x, area.y, area.width, area.height - 2); + let workspace = Rect::new(area.x, area.y, area.width, area.height - 1); let (editor_area, panel) = dock_layout(workspace, side, true); let panel = panel.unwrap(); assert_eq!(editor_area.area() + panel.area(), workspace.area()); @@ -10390,7 +10889,7 @@ end .unwrap(); let panel = panels.files.unwrap().0; assert_eq!(panel.x - area.x, area.right() - panel.right()); - assert!(panel.y >= area.y && panel.bottom() <= area.bottom() - 2); + assert!(panel.y >= area.y && panel.bottom() < area.bottom()); assert_eq!((top, left), before); assert_eq!( terminal.get_cursor_position().unwrap(), @@ -10400,7 +10899,6 @@ end assert_eq!(buffer[(panel.x, panel.y)].fg, Color::Cyan); assert_eq!(buffer[(panel.right() - 2, panel.y + 1)].symbol(), " "); assert_eq!(buffer[(area.x + 3, area.y)].symbol(), "x"); - assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan); assert_eq!(buffer[(0, 0)].symbol(), " "); } @@ -10588,12 +11086,8 @@ end }) .unwrap(); if width > 0 && height > 0 { - let workspace = Rect::new( - area.x, - area.y, - width, - height - u16::from(height >= 3) - u16::from(height >= 2), - ); + let workspace = + Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); let (_, panel) = dock_layout(workspace, side, true); let cursor = terminal.get_cursor_position().unwrap(); assert!( @@ -10618,12 +11112,8 @@ end if width > 0 && height > 0 { let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{side:?} {area:?}: {cursor:?}"); - let workspace = Rect::new( - area.x, - area.y, - width, - height - u16::from(height >= 3) - u16::from(height >= 2), - ); + let workspace = + Rect::new(area.x, area.y, width, height - u16::from(height >= 2)); let (editor_area, panel) = dock_layout(workspace, side, false); assert!(editor_area.contains(cursor), "{side:?}: {cursor:?}"); let buffer = terminal.backend().buffer(); @@ -10791,21 +11281,30 @@ end .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); - for (kind, top) in [ - (MouseEventKind::ScrollDown, 3), - (MouseEventKind::ScrollDown, 6), - (MouseEventKind::ScrollDown, 8), - (MouseEventKind::ScrollDown, 8), - (MouseEventKind::ScrollUp, 5), - (MouseEventKind::ScrollUp, 2), - (MouseEventKind::ScrollUp, 0), - (MouseEventKind::ScrollUp, 0), + let height = usize::from(viewports.document.geometry.unwrap().1.height); + let limit = session.editor().lines().len().saturating_sub(height); + let mut top = 0usize; + for kind in [ + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollDown, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, + MouseEventKind::ScrollUp, ] { + top = if kind == MouseEventKind::ScrollDown { + top.saturating_add(3).min(limit) + } else { + top.saturating_sub(3) + }; // The gutter belongs to the rendered document hit region too. assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); terminal .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); + assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, top); assert!( screen_text(&terminal) .lines() @@ -10815,7 +11314,7 @@ end ); assert_eq!( terminal.backend().cursor_visible(), - cursor.row >= top && cursor.row < top + 4 + cursor.row >= top && cursor.row < top + height ); assert_eq!(session.editor().cursor(), cursor); assert_eq!(session.editor().carets(), carets); @@ -11330,14 +11829,16 @@ end .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.document.text_width, 4); + let visible = viewports + .document + .visible + .iter() + .map(|&(row, part, _)| (row, part)) + .collect::>(); + assert!(visible.starts_with(&[(0, 0), (0, 1), (0, 2), (1, 0)])); assert_eq!( - viewports - .document - .visible - .iter() - .map(|&(row, part, _)| (row, part)) - .collect::>(), - [(0, 0), (0, 1), (0, 2), (1, 0)] + visible.len(), + usize::from(viewports.document.geometry.unwrap().1.height) ); let buffer = terminal.backend().buffer(); assert_eq!(buffer[(4, 0)].symbol(), "#"); @@ -11373,7 +11874,16 @@ end terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(viewports.offsets[&a_id], (11, 37)); + let initial = viewports.offsets[&a_id]; + assert_eq!( + initial.0, + editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) + ); + assert_eq!( + initial.1, + display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + 1 + - viewports.document.text_width + ); for _ in 0..2 { editor.handle(Input::Up); } @@ -11385,7 +11895,7 @@ end .unwrap(); let a_viewport = viewports.offsets[&a_id]; let a_cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(a_viewport, (11, 37)); + assert_eq!(a_viewport, initial); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); @@ -11409,7 +11919,6 @@ end .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); - assert_eq!(b_viewport, (5, 27)); editor.execute(&format!("buffer {a_id}")); terminal @@ -11496,7 +12005,10 @@ end terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(viewports.offsets[&a_id], (9, 29)); + assert_eq!( + viewports.offsets[&a_id].0, + editor.cursor().row + 1 - usize::from(viewports.document.geometry.unwrap().1.height) + ); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal @@ -11510,12 +12022,13 @@ end terminal .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); - assert_eq!(viewports.offsets[&a_id], (13, 49)); - assert_eq!(viewports.offsets[&b_id], (0, 0)); + let viewport = viewports.document.geometry.unwrap().1; assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(3, 1) + viewports.offsets[&a_id], + (editor.cursor().row + 1 - usize::from(viewport.height), 49,) ); + assert_eq!(viewports.offsets[&b_id], (0, 0)); + assert!(viewport.contains(terminal.get_cursor_position().unwrap())); for (width, height) in [(1, 1), (24, 8)] { let area = ratatui::layout::Rect::new(0, 0, width, height); @@ -11640,97 +12153,54 @@ end }, ) .unwrap(); - let (mut top, mut left) = (0, 0); - let mut syntax = SyntaxCache::new(); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - assert_eq!((top, left), (8, 8)); - let buffer = terminal.backend().buffer(); - for (y, expected) in [(2, " 2 "), (3, " 1 "), (4, "11 ")] { - let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect(); - assert_eq!(number, expected); - assert_eq!(buffer[(6, y)].symbol(), "8"); - assert_eq!(buffer[(3, y)].bg, Color::Reset); + let mut views = BufferViewports::default(); + for input in [ + None, + Some(Input::Down), + Some(Input::Up), + Some(Input::Up), + Some(Input::Ctrl('e')), + ] { + if let Some(input) = input { + editor.handle(input); + } + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + let viewport = views.panels.caret.unwrap().0; + assert_eq!(viewport.height, area.height - 1); + assert_eq!(viewport.x, area.x + 3); + let (top, left) = views.offsets[&editor.buffer_id()]; + assert_eq!(views.document.visible[0].0, top); + let buffer = terminal.backend().buffer(); + for (screen_row, &(row, _, _)) in views.document.visible.iter().enumerate() { + let y = viewport.y + screen_row as u16; + let number: String = (area.x..viewport.x) + .map(|x| buffer[(x, y)].symbol()) + .collect(); + let expected = if row == editor.cursor().row { + row + 1 + } else { + row.abs_diff(editor.cursor().row) + }; + assert_eq!(number, format!("{expected:>2} ")); + assert_eq!(buffer[(area.x, y)].bg, Color::Reset); + assert_eq!( + buffer[(area.x, y)].modifier.contains(Modifier::BOLD), + row == editor.cursor().row + ); + } + let cursor = terminal.get_cursor_position().unwrap(); + assert!(viewport.contains(cursor)); + assert_eq!( + cursor.x, + viewport.x + + (display_column(&editor.lines()[editor.cursor().row], editor.cursor().column) + - left) as u16 + ); + assert_eq!(cursor.y, viewport.y + (editor.cursor().row - top) as u16); } - assert!(buffer[(3, 4)].modifier.contains(Modifier::BOLD)); - assert!(!buffer[(3, 3)].modifier.contains(Modifier::BOLD)); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(10, 4) - ); - editor.handle(Input::Down); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - let last_number: String = (3..6).map(|x| buffer[(x, 4)].symbol()).collect(); - assert_eq!(last_number, "12 "); - assert_eq!(buffer[(6, 4)].symbol(), " "); - assert_eq!( - terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(6, 4) - ); - editor.handle(Input::Up); - editor.handle(Input::Up); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (y, expected) in [(2, "10 "), (3, " 1 "), (4, " 2 ")] { - let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect(); - assert_eq!(number, expected); - } - editor.handle(Input::Ctrl('e')); assert_eq!(editor.selection_count(), 2); - terminal - .draw(|frame| { - draw( - frame, - &editor, - &mut top, - &mut left, - &mut syntax, - &mut DocumentViewport::default(), - &mut WheelPanels::default(), - ) - }) - .unwrap(); - let buffer = terminal.backend().buffer(); - for (y, expected) in [(2, " 1 "), (3, "11 "), (4, " 1 ")] { - let number: String = (3..6).map(|x| buffer[(x, y)].symbol()).collect(); - assert_eq!(number, expected); - } } #[test] @@ -12153,18 +12623,10 @@ end assert!(area.contains(cursor), "{area:?}: {cursor:?}"); let buffer = terminal.backend().buffer(); if prompting { - assert_eq!(cursor.y, area.bottom() - 1); - assert_eq!(buffer[(area.x, cursor.y)].symbol(), "g"); + assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); } else { assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), "z"); } - if height >= 3 { - assert_eq!(buffer[(area.x, area.bottom() - 2)].bg, Color::Cyan); - } else { - for y in area.y..area.bottom() { - assert_ne!(buffer[(area.x, y)].bg, Color::Cyan); - } - } assert_eq!(buffer[(0, 0)].symbol(), " "); } } @@ -12214,8 +12676,6 @@ end assert!(matches!(buffer[(2, y)].bg, Color::Blue | Color::Magenta)); } } - let status: String = (0..48).map(|x| buffer[(x, 2)].symbol()).collect(); - assert!(status.contains("5 selections"), "{status}"); assert_eq!( terminal.get_cursor_position().unwrap(), ratatui::layout::Position::new(2, (editor.cursor().row - top) as u16) @@ -12296,10 +12756,6 @@ end }) .unwrap(); let buffer = frame.buffer; - if width == 16 { - let status: String = (0..width).map(|x| buffer[(x, 4)].symbol()).collect(); - assert!(status.contains("VISUAL LINE"), "{status}"); - } let gutter = if width > 2 { 2 } else { 0 }; for row in 0..3 { for x in 0..gutter { @@ -12504,6 +12960,7 @@ end for ch in "界界".chars() { editor.handle(Input::Char(ch)); } + let mut panels = WheelPanels::default(); terminal .draw(|frame| { draw( @@ -12513,13 +12970,15 @@ end &mut left, &mut syntax, &mut DocumentViewport::default(), - &mut WheelPanels::default(), + &mut panels, ) }) .unwrap(); let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(1, 4)].symbol(), "界"); - assert_eq!(buffer[(3, 4)].symbol(), "界"); + let input = panels.input.unwrap(); + let content = Rect::new(input.x + 1, input.y + 1, input.width - 2, 1); + assert_eq!(buffer[(content.x, content.y)].symbol(), "界"); + assert_eq!(buffer[(content.x + 2, content.y)].symbol(), "界"); editor.handle(Input::Backspace); editor.handle(Input::Backspace); editor.handle(Input::Char('\t')); @@ -12532,13 +12991,22 @@ end &mut left, &mut syntax, &mut DocumentViewport::default(), - &mut WheelPanels::default(), + &mut panels, ) }) .unwrap(); + let query = format!("/{}", editor.command_line()); + let column = display_column(&query, usize::MAX); + let offset = column.saturating_sub(usize::from(content.width).saturating_sub(1)); + assert!( + panels + .input + .unwrap() + .contains(terminal.get_cursor_position().unwrap()) + ); assert_eq!( terminal.get_cursor_position().unwrap(), - ratatui::layout::Position::new(4, 4) + Position::new(content.x + (column - offset) as u16, content.y), ); } diff --git a/tests/lsp.rs b/tests/lsp.rs index 1d2dcf0..68b7bee 100644 --- a/tests/lsp.rs +++ b/tests/lsp.rs @@ -91,7 +91,20 @@ loop do 'tokenModifiers' => ['custom', 'readonly', 'declaration', 'async', 'defaultLibrary']}, 'range' => true, 'full' => mode.include?('range') ? false : {'delta' => true}} end - result(initialize_id, {'capabilities' => capabilities}) + response = {'capabilities' => capabilities} + response['serverInfo'] = {'name' => "Fixture λ #{File.basename(log, '.jsonl')}"} + case mode + when 'name-absent' then response.delete('serverInfo') + when 'name-empty' then response['serverInfo']['name'] = '' + when 'name-blank' then response['serverInfo']['name'] = ' ' + when 'name-controls' then response['serverInfo']['name'] = "bad\e\nname" + when 'name-edge-controls' then response['serverInfo']['name'] = "\nFixture\n" + when 'name-type' then response['serverInfo']['name'] = 42 + when 'initialize-error' + emit({'id' => initialize_id, 'error' => {'code' => -32603, 'message' => 'fixture initialization failed'}}) + when 'initialize-invalid' then response['capabilities'] = nil + end + result(initialize_id, response) unless mode == 'initialize-error' initialized_sent = true end if !held.empty? && File.exist?(release) && ['stale', 'hover-stale'].include?(mode) @@ -300,7 +313,7 @@ impl Fixture { fn open(&self, mode: &str) -> EditorSession { let server = self.root.join("server.rb"); fs::write(&server, SERVER).unwrap(); - let command = if mode == "spawn" { + let mut command = if mode == "spawn" { json!([self.root.join("unavailable-language-server")]) } else { json!([ @@ -314,6 +327,12 @@ impl Fixture { self.alternate ]) }; + if mode == "name-absent" { + command + .as_array_mut() + .unwrap() + .insert(0, json!("/usr/bin/env")); + } let options = json!({ "extensions": if mode == "irrelevant" { vec!["myl"] } else { vec!["rs"] }, "command": command, @@ -492,6 +511,106 @@ fn edit_target(session: &mut EditorSession, fixture: &Fixture) -> usize { id } +#[test] +fn lsp_ready_names_wait_for_initialization_and_follow_only_the_focused_buffer() { + let fixture = Fixture::new(); + let mut session = fixture.open("controlled-gated"); + assert!(session.editor().lsp_names().is_empty()); + assert_eq!(session.editor().lsp_label(), ""); + until(&mut session, &fixture, |_| { + fixture.method_count("initialize") == 1 + }); + assert!(session.editor().lsp_names().is_empty()); + + let unserved = fixture.root.join("notes.txt"); + fs::write(&unserved, "unserved\n").unwrap(); + session.execute(&format!("edit {}", unserved.display())); + let unserved_id = session.editor().buffer_id(); + fixture.release(); + until(&mut session, &fixture, |_| { + fixture.method_count("textDocument/didOpen") == 1 + }); + session.poll(); + assert!(session.editor().lsp_names().is_empty()); + assert_eq!(session.editor().lsp_label(), ""); + + session.execute("buffer 1"); + assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]); + assert_eq!(session.editor().lsp_label(), "Fixture λ protocol"); + let revision = session.editor().text_revision(); + let cursor = session.editor().cursor(); + session.poll(); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().mode(), Mode::Normal); + + session.execute(&format!("buffer {unserved_id}")); + assert!(session.editor().lsp_names().is_empty()); + assert_eq!(session.editor().lsp_label(), ""); + session.execute("buffer 1"); + assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]); + fixture.control(0, 1, json!({"action": "die"})); + until(&mut session, &fixture, |session| { + session.editor().message().contains("server stopped") + }); + assert!(session.editor().lsp_names().is_empty()); + assert_eq!(session.editor().lsp_label(), ""); +} + +#[test] +fn lsp_readiness_alone_requests_a_redraw_without_document_or_message_changes() { + let fixture = Fixture::new(); + let mut session = fixture.open("controlled-gated"); + until(&mut session, &fixture, |_| { + fixture.method_count("initialize") == 1 + }); + assert!(session.editor().lsp_names().is_empty()); + let message = session.editor().message().to_owned(); + let revision = session.editor().text_revision(); + let cursor = session.editor().cursor(); + fixture.release(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let changed = session.poll(); + if !session.editor().lsp_names().is_empty() { + assert!(changed, "readiness must redraw even without plugin effects"); + break; + } + assert!( + Instant::now() < deadline, + "fixture server did not become ready" + ); + thread::sleep(Duration::from_millis(2)); + } + assert_eq!(session.editor().lsp_names(), ["Fixture λ protocol"]); + assert_eq!(session.editor().message(), message); + assert_eq!(session.editor().text_revision(), revision); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(session.editor().mode(), Mode::Normal); + assert_eq!(session.editor().diagnostics().count(), 0); +} + +#[test] +fn lsp_ready_names_fall_back_to_executable_only_for_missing_or_invalid_server_names() { + for mode in [ + "name-absent", + "name-empty", + "name-blank", + "name-controls", + "name-edge-controls", + "name-type", + ] { + let fixture = Fixture::new(); + let mut session = fixture.open(mode); + until(&mut session, &fixture, |session| { + !session.editor().lsp_names().is_empty() + }); + let expected = if mode == "name-absent" { "env" } else { "ruby" }; + assert_eq!(session.editor().lsp_names(), [expected], "{mode}"); + assert_eq!(session.editor().lsp_label(), expected, "{mode}"); + } +} + #[test] fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() { let fixture = Fixture::new(); @@ -885,6 +1004,8 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() { "spawn", "malformed", "dead", + "initialize-error", + "initialize-invalid", "unsupported", "remote", "bounds", @@ -892,7 +1013,10 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() { ] { let fixture = Fixture::new(); let mut session = fixture.open(mode); - if matches!(mode, "spawn" | "malformed" | "dead") { + if matches!( + mode, + "spawn" | "malformed" | "dead" | "initialize-error" | "initialize-invalid" + ) { until(&mut session, &fixture, |session| { session.editor().message().contains(if mode == "spawn" { "failed to start" @@ -901,6 +1025,8 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() { }) }); assert_eq!(session.editor().diagnostics().count(), 0); + assert!(session.editor().lsp_names().is_empty(), "{mode}"); + assert_eq!(session.editor().lsp_label(), "", "{mode}"); } else { until(&mut session, &fixture, |session| { session.editor().diagnostics().count() == 2 @@ -1285,11 +1411,13 @@ fn publish( fn lsp_python_stack_merges_in_assignment_order_and_routes_positions_only_to_ty() { let fixture = Fixture::new(); let mut session = fixture.open_stack(["controlled-gated", "controlled"], true); - until(&mut session, &fixture, |_| { + until(&mut session, &fixture, |session| { fixture.worker_count(0, "initialize") == 1 && fixture.worker_count(1, "textDocument/didOpen") == 1 + && session.editor().lsp_names() == ["Fixture λ protocol-1"] }); assert_eq!(fixture.worker_count(0, "textDocument/didOpen"), 0); + assert_eq!(session.editor().lsp_label(), "Fixture λ protocol-1"); session.handle(Input::Char('A')); session.handle(Input::Char('!')); session.handle(Input::Escape); @@ -1298,9 +1426,14 @@ fn lsp_python_stack_merges_in_assignment_order_and_routes_positions_only_to_ty() fixture.release(); until(&mut session, &fixture, |session| { session.editor().hover().is_some() + && session.editor().lsp_names() == ["Fixture λ protocol", "Fixture λ protocol-1"] }); assert_eq!(fixture.worker_count(0, "textDocument/hover"), 1); assert_eq!(fixture.worker_count(1, "textDocument/hover"), 0); + assert_eq!( + session.editor().lsp_label(), + "Fixture λ protocol + Fixture λ protocol-1" + ); for worker in 0..2 { let frames = fixture.worker_frames(worker); let initialize = frames @@ -1551,8 +1684,16 @@ fn lsp_python_stack_failures_retire_only_the_offender_in_active_and_parked_buffe }); } assert_eq!(diagnostic_messages(&session), ["healthy target"]); + let name = if survivor == 0 { + "Fixture λ protocol" + } else { + "Fixture λ protocol-1" + }; + assert_eq!(session.editor().lsp_names(), [name]); + assert_eq!(session.editor().lsp_label(), name); session.execute("buffer 1"); assert_eq!(diagnostic_messages(&session), ["healthy source"]); + assert_eq!(session.editor().lsp_names(), [name]); session.handle(Input::Char('A')); session.handle(Input::Char('!')); session.handle(Input::Escape);