feat: configure chrome segments and anchor groups

This commit is contained in:
Valdemar Lorenzen
2026-10-12 11:07:40 +02:00
parent d55b7b65d2
commit 4cfd068f1c
14 changed files with 1731 additions and 371 deletions
+3 -1
View File
@@ -9,6 +9,7 @@ Contributor-facing module boundaries and implementation contracts. Project termi
One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend: One editor crate; the workspace also includes its patched Crossterm dependency. Terminal-coordinate types remain outside the editing backend:
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected. - `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
- `chrome`: owns validated tabline/statusline anchor groups, segment title templates and padding/edge options, builtin defaults, and named-value validation. It has no terminal geometry or render callbacks.
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations. - `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated. Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies. - `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
@@ -38,6 +39,7 @@ Frontends inspect `line_operator_pending()` for the optional Normal `d`/`c` pref
Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply. Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and grapheme cursors plus a current index, with no copied paths/text. Successful high-level navigation records departure/destination once; traversal uses unrecorded buffer activation and position placement, skips closed/clamped-duplicate locations, and truncates forward entries only on a distinct new jump. Session synchronization permanently invalidates pending LSP position requests after their source buffer/revision/caret/mode is left, so returning through history cannot revive an old definition reply.
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 `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 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 `tabline_config()` and `statusline_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 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 `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. 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.
@@ -56,7 +58,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. `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, a one-row status bar, and a one-row prompt/message area. Multiple tabs reserve a top row for numbered filenames, dirty markers, and active-tab styling; overflow keeps the active title visible. Scrolling, clipping, highlights, and cursor placement use the allocated regions, not terminal-wide offsets. Below three workspace rows the status bar is hidden; at one workspace row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. Layout and terminal-cell text mapping remain frontend-owned. The TUI uses Ratatui constraint-based layout for a flexible buffer viewport, a one-row statusline, and a one-row prompt/message area. Multiple tabs or visible custom top-line segments reserve a tabline row; overflow keeps the active tab title visible. Each chrome row renders configured ordered left/center/right groups, with center anchored to the full row rather than the remaining gap. 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. Below three workspace rows the statusline is hidden; at one workspace row the buffer remains visible unless an active command/search prompt takes priority. Zero-sized terminals render nothing. 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. 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.
+25 -5
View File
@@ -44,19 +44,39 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration. `config.diff_context_lines = 10` sets how many unchanged aligned lines appear above and below each diff change; it defaults to `10` and accepts nonnegative integers, including `0` for changes only. `config.diff_expand_lines = 15` sets how many additional lines each directional expansion reveals per keypress; it defaults to `15` and accepts positive integers. Both are bounded by the platform's Rust `usize` maximum (`18446744073709551615` on 64-bit systems). Floats, strings, booleans, negative or oversized values, and a zero expansion step reject the whole Ruby configuration.
### Tabline status label ### Tabline and statusline segments
Set `config.tabline_label` to a plain string for the right side of the top tab line: `config.tabline` and `config.statusline` each accept ordered `left`, `center`, and `right` arrays of segment names. Left and right groups anchor to their respective edges; the center group is centered on the whole row, not on the gap remaining between the other groups. Array order is display order within each group. Omitted groups and segment options retain defaults; an empty array removes that group.
```ruby ```ruby
Vamp.configure do |config| Vamp.configure do |config|
config.tabline_label = ENV.fetch("VAMP_STATUS", "") config.tabline = {
left: [:tabs], center: [], right: [:battery],
tabs: {
title: "{index} {name}{modified}", padding: 1,
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
},
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
}
config.statusline = {
left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer],
center: [], right: [:position],
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
}
config.labels = { battery: "bat 38%" }
end end
``` ```
The default `""` disables the label. A nonempty value shows the tab line even with one tab, using the existing accent background and left density fade. The label yields to the active tab when both cannot fit. Unicode is supported; control characters are replaced rather than interpreted as terminal escapes. Non-string values reject configuration. The top line's builtin is `:tabs`; the bottom line's builtins are `:mode`, `:path`, `:position`, `:language`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`. They provide the mode, file path/dirty state, caret position, detected language, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count. Using a builtin on the wrong line rejects configuration. Defaults retain the existing information and accent styling: the top has `left: [:tabs]`; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count, :language]` and `right: [:position]`; other groups are empty.
Configuration runs once at startup. For live values such as battery charge, load a [Ruby plugin](plugins.md#tabline-status-updates) that supplies updates; the editor does not collect battery data or run periodic shell commands itself. 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.
`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 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 `:battery`. 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 on either line.
Configuration runs once at startup. For live values, use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). The editor does not collect battery data, run periodic shell commands, or accept render callbacks; a plugin supplies values and chooses when to refresh them.
## Actions and modes ## Actions and modes
+1
View File
@@ -41,6 +41,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
- Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location. - Jump-history checks cover cross-buffer/same-document navigation, search and picker recording, counts and repeated visits, bounded history, branch truncation, closed buffers, edited-coordinate clamping, dirty text/undo preservation, Ctrl+i/Tab aliases, and custom bindings. Real LSP fixtures cover definition round-trips and delayed replies after leaving and returning to the same source location.
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting. - Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup. - 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, named startup values, and atomic live label batches. Rendering regressions should cover full-row center anchoring, terminal-cell glyph widths, empty custom-segment visibility, active-tab preservation, and padding inside styled edges. 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.
- 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. - 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. - 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. - 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.
+2 -2
View File
@@ -218,9 +218,9 @@ Use `:tab.new` to open the current buffer in a new, focused tab with one pane. E
| `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes | | `:tab.close` | `config.core.tab.close` | Close the current tab and all its panes, protecting unsaved changes |
| `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab | | `:tab.force_close` | `config.core.tab.force_close` | Explicitly discard exclusively displayed edits and close the tab |
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 omit the fade before clipping the active title. The status bar uses the same fade around its mode and right-aligned caret position, leaving file metadata unfilled. New/close actions have no default keybinding; for example, `config.bind "leader t", config.core.tab.new`. Multiple tabs show a single top row with numbered filenames and dirty markers, with padding inside each tab's styling. By default, 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`.
An optional [configured status label](configuration.md#tabline-status-label) occupies the right edge of this same row, with a left fade. It is session-wide and can update through plugins without editing buffers. A nonempty label also shows the row with a single tab; narrow layouts hide the label rather than overlap the active tab. Both the top tabline and bottom statusline use [configurable ordered segments](configuration.md#tabline-and-statusline-segments), anchored left, truly centered on the full row, or anchored right. Title templates, internal padding, and styled edge glyphs can be configured independently. Custom named values such as a battery label are session-wide and can update through plugins without editing buffers. A visible custom segment also shows the top row with a single tab; empty values hide their custom segments. 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.
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. 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.
+16 -10
View File
@@ -22,7 +22,7 @@ Callbacks receive an immutable primary-only context snapshot: `editor.text`, `ed
The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does. The Ruby host and examples use [YARD types](https://solargraph.org/guides/yard) for Solargraph navigation and hover. `Vamp.command` declares typed block arguments (`Vamp::Editor`, `String`); the example also annotates them explicitly. Context readers and configuration methods are annotated in [`src/ruby_config.rb`](../src/ruby_config.rb); asynchronous callback/effect contracts live in [`src/ruby_plugin.rb`](../src/ruby_plugin.rb). These comments do not enforce types at runtime; existing boundary validation still does.
`editor.insert(text)`, `editor.replace(text)`, `editor.message(text)`, and `editor.tabline_label(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message. `editor.insert(text)`, `editor.replace(text)`, `editor.message(text)`, and `editor.label(name, text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message.
This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown. This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown.
@@ -40,33 +40,39 @@ A feature declares all capabilities it needs. Registration is atomic: if any req
`Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input. `Vamp.on_document { |event| ... }` receives a deeply frozen hash with `"event"` (`"open"`, `"change"`, `"save"`, or `"close"`) and `"document"` (`"id"`, `"revision"`, `"path"`, `"root"`, `"text"`). Already-open documents are delivered at registration; changes include hidden-buffer edits, undo/redo, and plugin edits. Save-as sends close/open for the changed path. `Vamp.on_shutdown { ... }` supplies a shutdown hook. Callbacks must enqueue slow work rather than blocking host input.
Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, `tabline_label`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations. Worker threads call `Vamp.publish(effects, request_id: nil)`. Background effects are `message`, `label`, revision-tagged `diagnostics`, or request-correlated `navigate`/`hover`; they cannot edit text. Diagnostics carry `buffer_id`, `revision`, and `items`, each with end-exclusive `start`/`end` positions, `severity` (`error`, `warning`, `information`, `hint`), and `message`. Positions are **zero-based grapheme coordinates**, not bytes, terminal cells, or UTF-16. Empty item lists clear the owner's annotations.
Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics. Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`column`, and the invocation's `editor.request_id` passed to `publish`. Only the latest still-current source request may move the primary. Closed/stale revisions are ignored; malformed current updates retire their owner. Failed navigation reports an error without discarding buffer edits. Hosts use bounded queues/frames; protocol failure or host exit retires commands, bindings, pending requests, and owned diagnostics.
`hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content. `hover` carries source `buffer_id`/`revision` and multiline `text`, with the invocation's `editor.request_id` passed to `publish`. Only applicable current requests in Normal mode can show a popup; stale results and replies while another modal list is focused are ignored. Text validates before a batch changes state; empty text clears the popup, and owner retirement dismisses its content.
## Tabline status updates ## Named label updates
`editor.tabline_label(text)` sets the same session-wide label as `config.tabline_label`. It is a presentation effect: it does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views. `editor.label(name, text)` sets a session-wide named value initialized by `config.labels`. Place that name in a [tabline or statusline anchor group](configuration.md#tabline-and-statusline-segments) to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
```ruby ```ruby
Vamp.plugin "tabline_status" Vamp.plugin "battery_label"
# @param editor [Vamp::Editor] # @param editor [Vamp::Editor]
# @param args [String] # @param args [String]
Vamp.command "tablabel" do |editor, args| Vamp.command "battery" do |editor, args|
editor.tabline_label(args) editor.label("battery", args)
end end
``` ```
Load this plugin with `config.plugin`, then use `:tablabel bat 38%`; `:tablabel` clears it. For asynchronous data, an `async: true` plugin's worker can publish `{ type: "tabline_label", text: label }` through `Vamp.publish`, without a request ID. Collect data and choose the refresh interval in the plugin, not in a render callback; stop worker threads in `Vamp.on_shutdown`. Updates redraw while idle and persist across tabs, without depending on the originating buffer or revision. The last value remains until overwritten or cleared, including after plugin retirement. Load this plugin with `config.plugin`, then use `:battery bat 38%`; `:battery` clears the value and hides its segment. For asynchronous data, an `async: true` plugin's worker publishes a named value without a request ID:
Labels are plain strings: control characters become replacement glyphs, and an empty string clears the label. Updates share the existing bounded protocol; rejected batches cannot partially change the label. ```ruby
Vamp.publish([{ type: "label", name: "battery", text: "bat 38%" }])
```
Collect data and choose the refresh interval in the plugin; there is no builtin battery collection or render callback. Stop worker threads in `Vamp.on_shutdown`. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.
Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.
## Messaging ## Messaging
Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`, `tabline_label`). Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB. Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands, async opt-in) or `config` (plugin paths/options, bindings, chrome configuration, and named labels). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`, `label`). A `label` effect carries `name` and `text` strings. Async hosts also receive `document`/`shutdown` and send `update` frames with optional request IDs and typed presentation/navigation effects. Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB.
Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`. Before loading a script, Rust supplies a bounded startup JSON envelope containing `core_actions` and `options`. The action catalog initializes the Ruby interface; only the separate user-options object is exposed through `Vamp.options`, including when user options contain a key named `core_actions`.
+15 -1
View File
@@ -7,7 +7,21 @@ Vamp.configure do |config|
config.file_explorer_side = "right" config.file_explorer_side = "right"
config.file_explorer_view = "tree" config.file_explorer_view = "tree"
config.file_picker_view = "tree" config.file_picker_view = "tree"
# config.tabline_label = ENV.fetch("VAMP_STATUS", "") # Right-aligned top-line label config.tabline = {
left: [:tabs], center: [], right: [:battery],
tabs: {
title: "{index} {name}{modified}", padding: 1,
leading_edge: "░▒▓", trailing_edge: "▓▒░", first_leading_edge: ""
},
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
}
config.statusline = {
left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer],
center: [], right: [:position],
mode: { padding: 1, leading_edge: "", trailing_edge: "▓▒░" },
position: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
}
config.labels = { battery: "bat 38%" } # Static example; plugins update via editor.label(name, text)
config.bind "ctrl+j", config.core.down, mode: config.modes.selection config.bind "ctrl+j", config.core.down, mode: config.modes.selection
config.bind "ctrl+k", config.core.up, mode: config.modes.selection config.bind "ctrl+k", config.core.up, mode: config.modes.selection
config.plugin "plugins/greeting.rb" config.plugin "plugins/greeting.rb"
+313
View File
@@ -0,0 +1,313 @@
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
const STATUS_SEGMENTS: &[&str] = &[
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"position",
];
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ChromeLine {
pub left: Vec<String>,
pub center: Vec<String>,
pub right: Vec<String>,
pub segments: BTreeMap<String, SegmentConfig>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SegmentConfig {
pub title: String,
pub padding: usize,
pub leading_edge: String,
pub trailing_edge: String,
pub first_leading_edge: Option<String>,
}
impl Default for SegmentConfig {
fn default() -> Self {
Self {
title: String::new(),
padding: 1,
leading_edge: String::new(),
trailing_edge: String::new(),
first_leading_edge: None,
}
}
}
impl ChromeLine {
pub fn tabline() -> Self {
Self {
left: vec!["tabs".into()],
center: Vec::new(),
right: Vec::new(),
segments: BTreeMap::from([("tabs".into(), default_segment("tabs"))]),
}
}
pub fn statusline() -> Self {
Self {
left: STATUS_SEGMENTS[..STATUS_SEGMENTS.len() - 1]
.iter()
.map(|name| (*name).into())
.collect(),
center: Vec::new(),
right: vec!["position".into()],
segments: STATUS_SEGMENTS
.iter()
.map(|name| ((*name).into(), default_segment(name)))
.collect(),
}
}
/// Styles are populated for every referenced name during configuration validation.
pub fn segment(&self, name: &str) -> &SegmentConfig {
self.segments.get(name).expect("validated chrome segment")
}
pub fn validate(&mut self, tabline: bool) -> Result<(), String> {
for name in self
.left
.iter()
.chain(&self.center)
.chain(&self.right)
.chain(self.segments.keys())
{
validate_name(name)?;
if (tabline && STATUS_SEGMENTS.contains(&name.as_str())) || (!tabline && name == "tabs")
{
return Err(format!(
"Chrome segment {name:?} is only available in the {}",
if tabline { "statusline" } else { "tabline" }
));
}
}
for (name, segment) in &self.segments {
if segment.padding > 64 {
return Err(format!(
"Chrome segment {name:?} padding must be between 0 and 64"
));
}
for (option, text) in [
("title", Some(&segment.title)),
("leading_edge", Some(&segment.leading_edge)),
("trailing_edge", Some(&segment.trailing_edge)),
("first_leading_edge", segment.first_leading_edge.as_ref()),
] {
if text.is_some_and(|text| text.chars().any(char::is_control)) {
return Err(format!(
"Chrome segment {name:?} {option} must be single-line text without controls"
));
}
}
validate_title(&segment.title)
.map_err(|error| format!("Chrome segment {name:?}: {error}"))?;
}
for name in self.left.iter().chain(&self.center).chain(&self.right) {
if !self.segments.contains_key(name) {
self.segments.insert(name.clone(), default_segment(name));
}
}
Ok(())
}
}
fn default_segment(name: &str) -> SegmentConfig {
let mut segment = SegmentConfig::default();
if matches!(name, "tabs" | "position") {
segment.leading_edge = "░▒▓".into();
}
if matches!(name, "tabs" | "mode") {
segment.trailing_edge = "▓▒░".into();
}
if name == "tabs" {
segment.title = "{index} {name}{modified}".into();
segment.first_leading_edge = Some(String::new());
}
segment
}
pub fn validate_name(name: &str) -> Result<(), String> {
let mut bytes = name.bytes();
if !bytes
.next()
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
|| !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
return Err(format!(
"Label/segment name {name:?} must match [A-Za-z_][A-Za-z0-9_]*"
));
}
Ok(())
}
fn validate_title(title: &str) -> Result<(), String> {
let mut rest = title;
while let Some(index) = rest.find(['{', '}']) {
rest = &rest[index..];
if !rest.starts_with('{') {
return Err("title contains an unmatched '}'".into());
}
let end = rest.find('}').ok_or("title contains an unmatched '{'")?;
let placeholder = &rest[1..end];
if !matches!(placeholder, "index" | "name" | "path" | "modified") {
return Err(format!(
"unknown title placeholder {{{placeholder}}}; expected index, name, path, or modified"
));
}
rest = &rest[end + 1..];
}
Ok(())
}
#[derive(Deserialize)]
struct LineOptions {
left: Option<Vec<String>>,
center: Option<Vec<String>>,
right: Option<Vec<String>>,
#[serde(flatten)]
segments: BTreeMap<String, SegmentOptions>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SegmentOptions {
title: Option<String>,
padding: Option<usize>,
leading_edge: Option<String>,
trailing_edge: Option<String>,
first_leading_edge: Option<String>,
}
fn deserialize_line<'de, D: Deserializer<'de>>(
deserializer: D,
tabline: bool,
) -> Result<ChromeLine, D::Error> {
// Decode through a value so explicit null is rejected rather than treated as an omitted option.
let value = serde_json::Value::deserialize(deserializer)?;
let object = value
.as_object()
.ok_or_else(|| serde::de::Error::custom("chrome line must be an object"))?;
for (name, value) in object {
if value.is_null()
|| value
.as_object()
.is_some_and(|options| options.values().any(serde_json::Value::is_null))
{
return Err(serde::de::Error::custom(format!(
"Chrome option {name:?} must not be null"
)));
}
}
let options: LineOptions = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
let mut line = if tabline {
ChromeLine::tabline()
} else {
ChromeLine::statusline()
};
if let Some(left) = options.left {
line.left = left;
}
if let Some(center) = options.center {
line.center = center;
}
if let Some(right) = options.right {
line.right = right;
}
for (name, options) in options.segments {
let segment = line
.segments
.entry(name.clone())
.or_insert_with(|| default_segment(&name));
if let Some(title) = options.title {
segment.title = title;
}
if let Some(padding) = options.padding {
segment.padding = padding;
}
if let Some(edge) = options.leading_edge {
segment.leading_edge = edge;
}
if let Some(edge) = options.trailing_edge {
segment.trailing_edge = edge;
}
if let Some(edge) = options.first_leading_edge {
segment.first_leading_edge = Some(edge);
}
}
line.validate(tabline).map_err(serde::de::Error::custom)?;
Ok(line)
}
pub fn tabline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, true)
}
pub fn statusline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, false)
}
pub fn labels<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<String, String>, D::Error> {
let labels = BTreeMap::<String, String>::deserialize(deserializer)?;
for name in labels.keys() {
validate_name(name).map_err(serde::de::Error::custom)?;
}
Ok(labels)
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "tabline")]
top: ChromeLine,
#[serde(deserialize_with = "statusline")]
bottom: ChromeLine,
}
#[test]
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": {"right": ["battery"], "tabs": {"title": "T{index}:{path}{modified}"}, "battery": {"padding": 0}},
"bottom": {"left": [], "center": ["workspace"]}
})).unwrap();
assert_eq!(lines.top.left, ["tabs"]);
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.segment("workspace"), &SegmentConfig::default());
}
#[test]
fn malformed_chrome_is_rejected_at_the_boundary() {
for top in [
serde_json::json!({"left": ["bad-name"]}),
serde_json::json!({"left": ["mode"]}),
serde_json::json!({"left": null}),
serde_json::json!({"tabs": {"padding": 65}}),
serde_json::json!({"tabs": {"padding": null}}),
serde_json::json!({"tabs": {"leading_edge": "\n"}}),
serde_json::json!({"tabs": {"title": "{unknown}"}}),
serde_json::json!({"tabs": {"title": "{name"}}),
serde_json::json!({"tabs": {"title": "name}"}}),
serde_json::json!({"tabs": {"colour": "red"}}),
] {
assert!(
serde_json::from_value::<Lines>(serde_json::json!({"top": top, "bottom": {}}))
.is_err()
);
}
}
}
+230 -67
View File
@@ -1,3 +1,4 @@
use crate::chrome::{ChromeLine, validate_name};
use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::clipboard::{ClipboardMode, SystemClipboard};
use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget};
use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView};
@@ -179,7 +180,7 @@ pub enum Effect {
Insert(String), Insert(String),
Replace(String), Replace(String),
Message(String), Message(String),
TablineLabel(String), Label { name: String, text: String },
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1350,7 +1351,9 @@ pub struct Editor {
owned_system_clipboard: Option<(String, Vec<String>, bool)>, owned_system_clipboard: Option<(String, Vec<String>, bool)>,
last_edit: Option<EditRecipe>, last_edit: Option<EditRecipe>,
message: String, message: String,
tabline_label: String, tabline: ChromeLine,
statusline: ChromeLine,
labels: BTreeMap<String, String>,
quit: bool, quit: bool,
capabilities: Vec<String>, capabilities: Vec<String>,
features: HashMap<String, Feature>, features: HashMap<String, Feature>,
@@ -1436,7 +1439,9 @@ impl Editor {
owned_system_clipboard: None, owned_system_clipboard: None,
last_edit: None, last_edit: None,
message: String::new(), message: String::new(),
tabline_label: String::new(), tabline: ChromeLine::tabline(),
statusline: ChromeLine::statusline(),
labels: BTreeMap::new(),
quit: false, quit: false,
capabilities, capabilities,
features: HashMap::new(), features: HashMap::new(),
@@ -3826,8 +3831,14 @@ impl Editor {
pub fn message(&self) -> &str { pub fn message(&self) -> &str {
&self.message &self.message
} }
pub fn tabline_label(&self) -> &str { pub fn tabline_config(&self) -> &ChromeLine {
&self.tabline_label &self.tabline
}
pub fn statusline_config(&self) -> &ChromeLine {
&self.statusline
}
pub fn label(&self, name: &str) -> &str {
self.labels.get(name).map_or("", String::as_str)
} }
pub fn path(&self) -> Option<&Path> { pub fn path(&self) -> Option<&Path> {
self.active.path.as_deref() self.active.path.as_deref()
@@ -3868,19 +3879,46 @@ impl Editor {
}; };
} }
pub fn set_tabline_label(&mut self, text: String) -> bool { pub fn set_chrome_config(
let text = if text.chars().any(char::is_control) { &mut self,
text.chars() mut tabline: ChromeLine,
.map(|ch| if ch.is_control() { '\u{fffd}' } else { ch }) mut statusline: ChromeLine,
.collect() labels: BTreeMap<String, String>,
} else { ) -> Result<(), String> {
text tabline.validate(true)?;
}; statusline.validate(false)?;
if self.tabline_label == text { let mut total = 0usize;
return false; for (name, text) in &labels {
validate_label(name, text)?;
total = total
.checked_add(name.len())
.and_then(|total| total.checked_add(text.len()))
.ok_or("Labels exceed 4 MiB")?;
if total > MAX_EDIT_BYTES {
return Err("Labels exceed 4 MiB".into());
} }
self.tabline_label = text; }
true self.tabline = tabline;
self.statusline = statusline;
self.labels = labels
.into_iter()
.map(|(name, text)| (name, sanitize_label(text)))
.collect();
Ok(())
}
pub fn set_label(&mut self, name: String, text: String) -> Result<bool, String> {
validate_label(&name, &text)?;
let text = sanitize_label(text);
if self.label(&name) == text {
return Ok(false);
}
if text.is_empty() {
self.labels.remove(&name);
} else {
self.labels.insert(name, text);
}
Ok(true)
} }
pub fn register(&mut self, feature: Feature) -> Result<(), String> { pub fn register(&mut self, feature: Feature) -> Result<(), String> {
@@ -5767,24 +5805,7 @@ impl Editor {
{ {
return Err("Diff view is read-only".into()); return Err("Diff view is read-only".into());
} }
let mut total = 0usize; validate_effects(&effects)?;
for effect in &effects {
let text = match effect {
Effect::Insert(text)
| Effect::Replace(text)
| Effect::Message(text)
| Effect::TablineLabel(text) => text,
};
total = total
.checked_add(text.len())
.ok_or("Plugin effects exceed 4 MiB")?;
if total > MAX_EDIT_BYTES {
return Err("Plugin effects exceed 4 MiB".into());
}
if !matches!(effect, Effect::TablineLabel(_)) {
validate_text(text)?;
}
}
if !effects if !effects
.iter() .iter()
.any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_))) .any(|effect| matches!(effect, Effect::Insert(_) | Effect::Replace(_)))
@@ -5792,8 +5813,8 @@ impl Editor {
for effect in effects { for effect in effects {
match effect { match effect {
Effect::Message(text) => self.set_message(text), Effect::Message(text) => self.set_message(text),
Effect::TablineLabel(text) => { Effect::Label { name, text } => {
self.set_tabline_label(text); self.set_label(name, text)?;
} }
_ => unreachable!("presentation-only effects"), _ => unreachable!("presentation-only effects"),
} }
@@ -5837,8 +5858,8 @@ impl Editor {
self.normalize_targets(); self.normalize_targets();
} }
Effect::Message(text) => self.set_message(text), Effect::Message(text) => self.set_message(text),
Effect::TablineLabel(text) => { Effect::Label { name, text } => {
self.set_tabline_label(text); self.set_label(name, text)?;
} }
} }
} }
@@ -8296,6 +8317,54 @@ fn validate_paste_text(text: &str) -> Result<(), String> {
} }
} }
pub(crate) fn validate_effects(effects: &[Effect]) -> Result<(), String> {
let mut total = 0usize;
for effect in effects {
let text = match effect {
Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text,
Effect::Label { name, text } => {
validate_label(name, text)?;
total = total
.checked_add(name.len())
.ok_or("Plugin effects exceed 4 MiB")?;
text
}
};
total = total
.checked_add(text.len())
.ok_or("Plugin effects exceed 4 MiB")?;
if total > MAX_EDIT_BYTES {
return Err("Plugin effects exceed 4 MiB".into());
}
if !matches!(effect, Effect::Label { .. }) {
validate_text(text)?;
}
}
Ok(())
}
pub(crate) fn validate_label(name: &str, text: &str) -> Result<(), String> {
validate_name(name)?;
if name
.len()
.checked_add(text.len())
.is_none_or(|bytes| bytes > MAX_EDIT_BYTES)
{
return Err("Label exceeds 4 MiB".into());
}
Ok(())
}
fn sanitize_label(text: String) -> String {
if text.chars().any(char::is_control) {
text.chars()
.map(|ch| if ch.is_control() { '\u{fffd}' } else { ch })
.collect()
} else {
text
}
}
fn validate_text(text: &str) -> Result<(), String> { fn validate_text(text: &str) -> Result<(), String> {
if let Some(ch) = text if let Some(ch) = text
.chars() .chars()
@@ -16972,10 +17041,13 @@ mod tests {
editor editor
.apply(vec![ .apply(vec![
Effect::Message("ok".into()), Effect::Message("ok".into()),
Effect::TablineLabel("ready".into()), Effect::Label {
name: "battery".into(),
text: "ready".into(),
},
]) ])
.unwrap(); .unwrap();
assert_eq!(editor.tabline_label(), "ready"); assert_eq!(editor.label("battery"), "ready");
assert_eq!(editor.selections(), ranges); assert_eq!(editor.selections(), ranges);
assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.mode(), Mode::Visual);
editor.apply(vec![Effect::Insert("!".into())]).unwrap(); editor.apply(vec![Effect::Insert("!".into())]).unwrap();
@@ -17674,23 +17746,91 @@ mod tests {
} }
#[test] #[test]
fn tabline_label_sanitizes_once_and_is_global_until_cleared() { fn chrome_installation_and_invalid_label_batches_are_atomic() {
let mut editor = editor(); let mut editor = editor();
assert_eq!(editor.tabline_label(), ""); editor.set_label("battery".into(), "ready".into()).unwrap();
let text = String::from("界 e\u{301} 👩‍🚀"); let mut top = ChromeLine::tabline();
assert!(editor.set_tabline_label(text)); top.center.push("workspace".into());
assert!(!editor.set_tabline_label("界 e\u{301} 👩‍🚀".into())); let bottom = ChromeLine::statusline();
assert!(editor.set_tabline_label("界\0\t\r\n\u{1b}\u{85}👩‍🚀".into())); editor
assert_eq!(editor.tabline_label(), "界������👩‍🚀"); .set_chrome_config(
top.clone(),
bottom.clone(),
BTreeMap::from([
("battery".into(), "ready".into()),
("workspace".into(), "tools\n".into()),
]),
)
.unwrap();
assert_eq!(editor.label("workspace"), "tools�");
assert_eq!(
editor.tabline_config().segment("workspace"),
&crate::chrome::SegmentConfig::default()
);
let installed = editor.tabline_config().clone();
top.segments.get_mut("tabs").unwrap().title = "{missing}".into();
assert!(
editor
.set_chrome_config(top, bottom, BTreeMap::new())
.is_err()
);
assert_eq!(editor.tabline_config(), &installed);
assert_eq!(editor.label("battery"), "ready");
assert!(
editor
.apply(vec![
Effect::Message("must not leak".into()),
Effect::Label {
name: "battery".into(),
text: "changed".into()
},
Effect::Insert("must not edit".into()),
Effect::Label {
name: "bad-name".into(),
text: "invalid".into()
},
])
.is_err()
);
assert_eq!(editor.label("battery"), "ready");
assert_eq!(editor.message(), "");
assert_eq!(editor.context().text, "");
assert_eq!(editor.text_revision(), 0);
}
#[test]
fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() {
let mut editor = editor();
assert_eq!(editor.label("battery"), "");
assert!(
editor
.set_label("battery".into(), "界 e\u{301} 👩‍🚀".into())
.unwrap()
);
assert!(
!editor
.set_label("battery".into(), "界 e\u{301} 👩‍🚀".into())
.unwrap()
);
assert!(
editor
.set_label("battery".into(), "界\0\t\r\n\u{1b}\u{85}👩‍🚀".into())
.unwrap()
);
editor
.set_label("workspace".into(), "tools".into())
.unwrap();
assert_eq!(editor.label("battery"), "界������👩‍🚀");
editor.set_message("transient"); editor.set_message("transient");
editor.execute("tab.new"); editor.execute("tab.new");
assert_eq!(editor.tabline_label(), "界������👩‍🚀"); assert_eq!(editor.label("battery"), "界������👩‍🚀");
editor.execute("tab.previous"); editor.execute("tab.previous");
assert_eq!(editor.tabline_label(), "界������👩‍🚀"); assert_eq!(editor.label("workspace"), "tools");
assert!(editor.set_tabline_label(String::new())); assert!(editor.set_label("battery".into(), String::new()).unwrap());
editor.execute("tab.next"); editor.execute("tab.next");
assert_eq!(editor.tabline_label(), ""); assert_eq!(editor.label("battery"), "");
assert!(!editor.set_tabline_label(String::new())); assert!(!editor.set_label("battery".into(), String::new()).unwrap());
assert_eq!(editor.label("workspace"), "tools");
} }
#[test] #[test]
@@ -17710,11 +17850,14 @@ mod tests {
let history = (editor.active.undo.len(), editor.active.redo.len()); let history = (editor.active.undo.len(), editor.active.redo.len());
editor editor
.apply(vec![ .apply(vec![
Effect::TablineLabel("界\n\u{1b}".into()), Effect::Label {
name: "battery".into(),
text: "界\n\u{1b}".into(),
},
Effect::Message("read only".into()), Effect::Message("read only".into()),
]) ])
.unwrap(); .unwrap();
assert_eq!(editor.tabline_label(), "界��"); assert_eq!(editor.label("battery"), "界��");
assert_eq!(editor.message(), "read only"); assert_eq!(editor.message(), "read only");
assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.selections(), selection); assert_eq!(editor.selections(), selection);
@@ -17727,27 +17870,38 @@ mod tests {
assert!( assert!(
editor editor
.apply(vec![ .apply(vec![
Effect::TablineLabel("must not leak".into()), Effect::Label {
name: "battery".into(),
text: "must not leak".into()
},
Effect::Insert("rejected".into()), Effect::Insert("rejected".into()),
]) ])
.is_err() .is_err()
); );
assert_eq!(editor.tabline_label(), "界��"); assert_eq!(editor.label("battery"), "界��");
editor editor
.apply(vec![Effect::TablineLabel(String::new())]) .apply(vec![Effect::Label {
name: "battery".into(),
text: String::new(),
}])
.unwrap(); .unwrap();
assert_eq!(editor.tabline_label(), ""); assert_eq!(editor.label("battery"), "");
} }
#[test] #[test]
fn plugin_effects_validate_before_any_mutation_and_group_undo() { fn plugin_effects_validate_before_any_mutation_and_group_undo() {
let mut editor = editor(); let mut editor = editor();
editor.set_message("unchanged"); editor.set_message("unchanged");
editor.set_tabline_label("unchanged".into()); editor
.set_label("battery".into(), "unchanged".into())
.unwrap();
assert!( assert!(
editor editor
.apply(vec![ .apply(vec![
Effect::TablineLabel("must not leak".into()), Effect::Label {
name: "battery".into(),
text: "must not leak".into()
},
Effect::Insert("good".into()), Effect::Insert("good".into()),
Effect::Message("bad\u{1b}".into()) Effect::Message("bad\u{1b}".into())
]) ])
@@ -17755,31 +17909,37 @@ mod tests {
); );
assert_eq!(editor.context().text, ""); assert_eq!(editor.context().text, "");
assert_eq!(editor.message(), "unchanged"); assert_eq!(editor.message(), "unchanged");
assert_eq!(editor.tabline_label(), "unchanged"); assert_eq!(editor.label("battery"), "unchanged");
assert!(!editor.dirty()); assert!(!editor.dirty());
assert!( assert!(
editor editor
.apply(vec![ .apply(vec![
Effect::TablineLabel("x".into()), Effect::Label {
name: "battery".into(),
text: "x".into()
},
Effect::Insert("a".repeat(MAX_EDIT_BYTES)) Effect::Insert("a".repeat(MAX_EDIT_BYTES))
]) ])
.is_err() .is_err()
); );
assert_eq!(editor.context().text, ""); assert_eq!(editor.context().text, "");
assert_eq!(editor.tabline_label(), "unchanged"); assert_eq!(editor.label("battery"), "unchanged");
editor editor
.apply(vec![ .apply(vec![
Effect::Insert("one".into()), Effect::Insert("one".into()),
Effect::Insert("\ntwo".into()), Effect::Insert("\ntwo".into()),
Effect::Message("ok".into()), Effect::Message("ok".into()),
Effect::TablineLabel("updated".into()), Effect::Label {
name: "battery".into(),
text: "updated".into(),
},
]) ])
.unwrap(); .unwrap();
assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.context().text, "one\ntwo");
assert_eq!(editor.message(), "ok"); assert_eq!(editor.message(), "ok");
editor.handle(Input::Char('u')); editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, ""); assert_eq!(editor.context().text, "");
assert_eq!(editor.tabline_label(), "updated"); assert_eq!(editor.label("battery"), "updated");
editor.handle(Input::Char('U')); editor.handle(Input::Char('U'));
assert_eq!(editor.context().text, "one\ntwo"); assert_eq!(editor.context().text, "one\ntwo");
editor editor
@@ -17798,7 +17958,10 @@ mod tests {
let undo_len = editor.active.undo.len(); let undo_len = editor.active.undo.len();
editor editor
.apply(vec![ .apply(vec![
Effect::TablineLabel("typing".into()), Effect::Label {
name: "battery".into(),
text: "typing".into(),
},
Effect::Message("notice".into()), Effect::Message("notice".into()),
]) ])
.unwrap(); .unwrap();
+1
View File
@@ -1,3 +1,4 @@
pub mod chrome;
pub mod clipboard; pub mod clipboard;
pub mod diff; pub mod diff;
pub mod editor; pub mod editor;
+2 -1
View File
@@ -176,7 +176,8 @@ pub enum UpdateEffect {
Message { Message {
text: String, text: String,
}, },
TablineLabel { Label {
name: String,
text: String, text: String,
}, },
} }
+131 -40
View File
@@ -1,3 +1,4 @@
use crate::chrome::ChromeLine;
use crate::editor::{Context, Effect, Feature}; use crate::editor::{Context, Effect, Feature};
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect}; use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -8,7 +9,10 @@ use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::thread::{self, JoinHandle}; use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::{collections::HashSet, env}; use std::{
collections::{BTreeMap, HashSet},
env,
};
const RUNNER: &str = concat!( const RUNNER: &str = concat!(
include_str!("ruby_values.rb"), include_str!("ruby_values.rb"),
@@ -34,7 +38,9 @@ pub struct Config {
pub file_picker_view: String, pub file_picker_view: String,
pub file_explorer_view: String, pub file_explorer_view: String,
pub clipboard_mode: String, pub clipboard_mode: String,
pub tabline_label: String, pub tabline: ChromeLine,
pub statusline: ChromeLine,
pub labels: BTreeMap<String, String>,
pub diff_context_lines: usize, pub diff_context_lines: usize,
pub diff_expand_lines: usize, pub diff_expand_lines: usize,
} }
@@ -71,7 +77,9 @@ impl Default for Config {
file_picker_view: "tree".into(), file_picker_view: "tree".into(),
file_explorer_view: "tree".into(), file_explorer_view: "tree".into(),
clipboard_mode: "system".into(), clipboard_mode: "system".into(),
tabline_label: String::new(), tabline: ChromeLine::tabline(),
statusline: ChromeLine::statusline(),
labels: BTreeMap::new(),
diff_context_lines: 10, diff_context_lines: 10,
diff_expand_lines: 15, diff_expand_lines: 15,
} }
@@ -94,7 +102,9 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view, file_picker_view,
file_explorer_view, file_explorer_view,
clipboard_mode, clipboard_mode,
tabline_label, tabline,
statusline,
labels,
diff_context_lines, diff_context_lines,
diff_expand_lines, diff_expand_lines,
.. ..
@@ -119,7 +129,9 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
config.file_picker_view = file_picker_view; config.file_picker_view = file_picker_view;
config.file_explorer_view = file_explorer_view; config.file_explorer_view = file_explorer_view;
config.clipboard_mode = clipboard_mode; config.clipboard_mode = clipboard_mode;
config.tabline_label = tabline_label; config.tabline = tabline;
config.statusline = statusline;
config.labels = labels;
config.diff_context_lines = diff_context_lines; config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines; config.diff_expand_lines = diff_expand_lines;
Ok(config) Ok(config)
@@ -350,7 +362,7 @@ fn convert_effects(effects: Vec<WireEffect>) -> Vec<Effect> {
WireEffect::Insert { text } => Effect::Insert(text), WireEffect::Insert { text } => Effect::Insert(text),
WireEffect::Replace { text } => Effect::Replace(text), WireEffect::Replace { text } => Effect::Replace(text),
WireEffect::Message { text } => Effect::Message(text), WireEffect::Message { text } => Effect::Message(text),
WireEffect::TablineLabel { text } => Effect::TablineLabel(text), WireEffect::Label { name, text } => Effect::Label { name, text },
}) })
.collect() .collect()
} }
@@ -438,7 +450,12 @@ enum Response {
file_picker_view: String, file_picker_view: String,
file_explorer_view: String, file_explorer_view: String,
clipboard_mode: String, clipboard_mode: String,
tabline_label: String, #[serde(deserialize_with = "crate::chrome::tabline")]
tabline: ChromeLine,
#[serde(deserialize_with = "crate::chrome::statusline")]
statusline: ChromeLine,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "diff_context_lines")] #[serde(deserialize_with = "diff_context_lines")]
diff_context_lines: usize, diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")] #[serde(deserialize_with = "diff_expand_lines")]
@@ -500,8 +517,8 @@ enum WireEffect {
Replace { text: String }, Replace { text: String },
#[serde(rename = "message")] #[serde(rename = "message")]
Message { text: String }, Message { text: String },
#[serde(rename = "tabline_label")] #[serde(rename = "label")]
TablineLabel { text: String }, Label { name: String, text: String },
} }
struct OutboundFrame { struct OutboundFrame {
@@ -890,8 +907,8 @@ Vamp.command "greet" do |editor, args|
editor.insert "Hello #{args}!" editor.insert "Hello #{args}!"
editor.replace editor.text editor.replace editor.text
editor.message "Greeting inserted" editor.message "Greeting inserted"
editor.tabline_label "界 e\u0301 👩‍🚀\t\n\e" editor.label :battery, "界 e\u0301 👩‍🚀\t\n\e"
editor.tabline_label "" editor.label "battery", ""
end end
"#); "#);
assert_eq!(feature.name, "greeting"); assert_eq!(feature.name, "greeting");
@@ -902,8 +919,9 @@ end
for _ in 0..2 { for _ in 0..2 {
let effects = plugin.invoke("greet", "friend", &context()).unwrap(); let effects = plugin.invoke("greet", "friend", &context()).unwrap();
assert!( assert!(
matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::TablineLabel(label), Effect::TablineLabel(cleared)] matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message), Effect::Label { name, text: label }, Effect::Label { name: cleared_name, text: cleared }]
if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted" if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted"
&& name == "battery" && cleared_name == "battery"
&& label == "界 e\u{301} 👩‍🚀\t\n\u{1b}" && cleared.is_empty()) && label == "界 e\u{301} 👩‍🚀\t\n\u{1b}" && cleared.is_empty())
); );
} }
@@ -1163,47 +1181,109 @@ end
assert!(load_config(&path).unwrap_err().contains(error), "{value}"); assert!(load_config(&path).unwrap_err().contains(error), "{value}");
} }
for frame in [ for frame in [
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline":{},"statusline":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":null,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline":{},"statusline":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline_label":"","diff_context_lines":10,"diff_expand_lines":15}"#, r#"{"type":"config","version":1,"plugins":[],"lsp":{},"bindings":[],"leader":7,"file_explorer_side":"right","file_picker_view":"list","file_explorer_view":"tree","clipboard_mode":"system","tabline":{},"statusline":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
] { ] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}"); assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
} }
} }
#[test] #[test]
fn config_tabline_label_defaults_accepts_empty_and_rejects_non_strings() { fn chrome_configuration_normalizes_ruby_values_and_retains_unspecified_defaults() {
let fixture = Fixture::new(); let fixture = Fixture::new();
for (value, expected) in [ let path = fixture.file(
(r#""""#, ""), "config.rb",
(r#""界 e\u0301 👩‍🚀\t\n\e""#, "界 e\u{301} 👩‍🚀\t\n\u{1b}"), r#"
Vamp.configure do |config|
config.tabline = {left: [:tabs], center: ["workspace"], right: [:battery],
tabs: {title: "T{index}:{path}{modified}", padding: 0, first_leading_edge: ""},
battery: {leading_edge: "界", trailing_edge: ""}}
config.statusline = {"left" => [], center: [:workspace]}
config.labels = {battery: "bat 38%", "workspace" => "界 e\u0301 👩‍🚀\t\n\e"}
end
"#,
);
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.tabline.left, ["tabs"]);
assert_eq!(loaded.tabline.center, ["workspace"]);
assert_eq!(loaded.tabline.right, ["battery"]);
assert_eq!(
loaded.tabline.segment("tabs").title,
"T{index}:{path}{modified}"
);
assert_eq!(loaded.tabline.segment("tabs").padding, 0);
assert_eq!(loaded.tabline.segment("tabs").trailing_edge, "▓▒░");
assert_eq!(loaded.tabline.segment("battery").leading_edge, "界");
assert_eq!(
loaded.tabline.segment("workspace"),
&crate::chrome::SegmentConfig::default()
);
assert!(loaded.statusline.left.is_empty());
assert_eq!(loaded.statusline.right, ["position"]);
assert_eq!(loaded.labels["battery"], "bat 38%");
assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}");
}
#[test]
fn chrome_configuration_rejects_invalid_types_names_and_templates() {
let fixture = Fixture::new();
for (setting, value, expected) in [
("tabline", "[]", "must be a Hash"),
("tabline", "{left: :tabs}", "must be an Array"),
("tabline", "{left: [7]}", "must be a String"),
("tabline", "{left: [:bad_name!]}", "must match"),
(
"tabline",
"{left: [:mode]}",
"only available in the statusline",
),
(
"statusline",
"{right: [:tabs]}",
"only available in the tabline",
),
(
"tabline",
"{tabs: {title: '{unknown}'}}",
"unknown title placeholder",
),
("tabline", r#"{tabs: {leading_edge: "\n"}}"#, "single-line"),
("tabline", "{tabs: {padding: 65}}", "between 0 and 64"),
("tabline", "{tabs: {padding: '1'}}", "must be an Integer"),
("tabline", "{tabs: {title: nil}}", "must be a String"),
(
"tabline",
"{tabs: {color: 'red'}}",
"unknown chrome style option",
),
(
"tabline",
"{left: [], 'left' => []}",
"duplicate chrome key",
),
("labels", "[]", "must be a Hash"),
("labels", "{battery: 7}", "label text must be a String"),
("labels", "{'bad-name' => ''}", "must match"),
] { ] {
let path = fixture.file( let path = fixture.file(
"config.rb", "config.rb",
&format!("Vamp.configure {{ |config| config.tabline_label = {value} }}"), &format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
);
assert_eq!(load_config(&path).unwrap().tabline_label, expected);
}
for value in ["7", "nil", "true", "{}", "[]", "-> { 'label' }"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.tabline_label = {value} }}"),
); );
let error = load_config(&path).unwrap_err(); let error = load_config(&path).unwrap_err();
assert!( assert!(error.contains(expected), "{setting}={value}: {error}");
error.contains("tabline label must be a String"),
"{value}: {error}"
);
} }
} }
#[test] #[test]
fn tabline_label_wire_effects_require_string_text() { fn label_wire_effects_require_name_and_string_text() {
for frame in [ for frame in [
serde_json::json!({"type": "tabline_label"}), serde_json::json!({"type": "label", "text": ""}),
serde_json::json!({"type": "tabline_label", "text": null}), serde_json::json!({"type": "label", "name": 7, "text": ""}),
serde_json::json!({"type": "tabline_label", "text": 7}), serde_json::json!({"type": "label", "name": "battery"}),
serde_json::json!({"type": "tabline_label", "text": "", "extra": true}), serde_json::json!({"type": "label", "name": "battery", "text": null}),
serde_json::json!({"type": "label", "name": "battery", "text": 7}),
serde_json::json!({"type": "label", "name": "battery", "text": "", "extra": true}),
] { ] {
assert!(serde_json::from_value::<WireEffect>(frame.clone()).is_err()); assert!(serde_json::from_value::<WireEffect>(frame.clone()).is_err());
assert!(serde_json::from_value::<UpdateEffect>(frame).is_err()); assert!(serde_json::from_value::<UpdateEffect>(frame).is_err());
@@ -1230,7 +1310,7 @@ end
"version": 1, "type": "config", "plugins": [], "lsp": {}, "version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"tabline_label": "", "tabline": {}, "statusline": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7 "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
}); });
for setting in ["diff_context_lines", "diff_expand_lines"] { for setting in ["diff_context_lines", "diff_expand_lines"] {
@@ -1643,7 +1723,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {}, "version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right", "bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree", "file_picker_view": "tree", "file_explorer_view": "tree",
"tabline_label": "", "tabline": {}, "statusline": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15 "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
}); });
let Response::Config { lsp, .. } = let Response::Config { lsp, .. } =
@@ -1726,8 +1806,19 @@ end"#,
for mutation in [ for mutation in [
"return '['", "return '['",
"frame.delete('lsp')", "frame.delete('lsp')",
"frame.delete('tabline_label')", "frame.delete('tabline')",
"frame['tabline_label'] = 7", "frame.delete('statusline')",
"frame.delete('labels')",
"frame['tabline'] = 7",
"frame['statusline'] = []",
"frame['labels'] = {'battery' => 7}",
"frame['tabline'] = {'tabs' => {'title' => '{unknown}'}}",
"frame['tabline'] = {'tabs' => {'leading_edge' => \"\\n\"}}",
"frame['tabline'] = {'left' => nil}",
"frame['tabline'] = {'left' => ['bad-name']}",
"frame['tabline'] = {'tabs' => {'padding' => '1'}}",
"frame['tabline'] = {'tabs' => {'color' => 'red'}}",
"frame['labels'] = {'bad-name' => ''}",
"frame['leader'] = 7", "frame['leader'] = 7",
"frame['unexpected'] = true", "frame['unexpected'] = true",
] { ] {
+85 -13
View File
@@ -1,6 +1,17 @@
# frozen_string_literal: true # frozen_string_literal: true
module Vamp module Vamp
# @param name [String, Symbol]
# @return [String]
def self.chrome_name!(name)
name = name.to_s if name.is_a?(Symbol)
name = string!(name, "label/segment name")
unless /\A[A-Za-z_][A-Za-z0-9_]*\z/.match?(name)
raise ArgumentError, "label/segment name must match [A-Za-z_][A-Za-z0-9_]*"
end
name
end
# Collects and validates the Ruby startup configuration DSL. # Collects and validates the Ruby startup configuration DSL.
class Config class Config
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options # @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
@@ -16,7 +27,9 @@ module Vamp
@file_picker_view = "tree" @file_picker_view = "tree"
@file_explorer_view = "tree" @file_explorer_view = "tree"
@clipboard_mode = "system" @clipboard_mode = "system"
@tabline_label = "" @tabline = {}
@statusline = {}
@labels = {}
@diff_context_lines = 10 @diff_context_lines = 10
@diff_expand_lines = 15 @diff_expand_lines = 15
end end
@@ -63,10 +76,24 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode") @clipboard_mode = nonempty_string!(mode, "clipboard mode")
end end
# @param text [String] plain text displayed at the right of the tab line; empty clears it # @param options [Hash{String, Symbol => Object}]
# @return [String] # @return [Hash]
def tabline_label=(text) def tabline=(options)
@tabline_label = Vamp.string!(text, "tabline label") @tabline = chrome_line!(options)
end
# @param options [Hash{String, Symbol => Object}]
# @return [Hash]
def statusline=(options)
@statusline = chrome_line!(options)
end
# @param values [Hash{String, Symbol => String}]
# @return [Hash]
def labels=(values)
@labels = chrome_hash!(values).to_h do |name, text|
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
end
end end
# @param lines [Integer] nonnegative context lines around each diff change # @param lines [Integer] nonnegative context lines around each diff change
@@ -111,11 +138,51 @@ module Vamp
def response def response
{ "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings, { "version" => 1, "type" => "config", "plugins" => @plugins, "lsp" => lsp_options, "bindings" => @bindings,
"leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "tabline_label" => @tabline_label, "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"tabline" => @tabline, "statusline" => @statusline, "labels" => @labels,
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines } "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
end end
private private
# @param value [Hash{String, Symbol => Object}]
# @return [Hash{String => Object}]
def chrome_hash!(value)
raise TypeError, "chrome options must be a Hash" unless value.is_a?(Hash)
value.each_with_object({}) do |(key, item), result|
key = key.to_s if key.is_a?(Symbol)
key = Vamp.string!(key, "chrome key")
raise ArgumentError, "duplicate chrome key: #{key}" if result.key?(key)
result[key] = item
end
end
# @param options [Hash{String, Symbol => Object}]
# @return [Hash{String => Object}]
def chrome_line!(options)
chrome_hash!(options).to_h do |name, value|
if %w[left center right].include?(name)
raise TypeError, "#{name} must be an Array" unless value.is_a?(Array)
[name, value.map { |item| Vamp.chrome_name!(item) }]
else
Vamp.chrome_name!(name)
style = chrome_hash!(value).to_h do |option, item|
case option
when "padding"
unless item.is_a?(Integer) && item.between?(0, 64)
raise ArgumentError, "padding must be an Integer between 0 and 64"
end
when "title", "leading_edge", "trailing_edge", "first_leading_edge"
item = Vamp.string!(item, option)
else
raise ArgumentError, "unknown chrome style option: #{option}"
end
[option, item]
end
[name, style]
end
end
end
# @param value [Integer] # @param value [Integer]
# @param label [String] # @param label [String]
@@ -244,10 +311,12 @@ module Vamp
queue("message", text) queue("message", text)
end end
# @param text [String] plain text displayed at the right of the tab line; empty clears it # @param name [String, Symbol]
# @param text [String] named session-wide plain text; empty clears it
# @return [nil] # @return [nil]
def tabline_label(text) def label(name, text)
queue("tabline_label", text) name = Vamp.chrome_name!(name)
queue("label", text, name)
end end
private private
@@ -294,15 +363,18 @@ module Vamp
raise ArgumentError, "context buffer_id and revision must be nonnegative integers" raise ArgumentError, "context buffer_id and revision must be nonnegative integers"
end end
# @param type [String] insert, replace, message, or tabline_label # @param type [String] insert, replace, message, or label
# @param text [String] # @param text [String]
# @param name [String, nil]
# @return [nil] # @return [nil]
def queue(type, text) def queue(type, text, name = nil)
text = Vamp.string!(text, "effect text") text = Vamp.string!(text, "effect text")
@effect_bytes += text.bytesize @effect_bytes += text.bytesize + (name ? name.bytesize : 0)
raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME
@effects << { "type" => type, "text" => text } effect = { "type" => type, "text" => text }
effect["name"] = name if name
@effects << effect
nil nil
end end
end end
+178 -28
View File
@@ -220,7 +220,11 @@ impl EditorSession {
)); ));
} }
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines); editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
editor.set_tabline_label(config.tabline_label); if let Err(error) =
editor.set_chrome_config(config.tabline, config.statusline, config.labels)
{
notices.push(format!("chrome: {error}"));
}
let mut plugins = BTreeMap::new(); let mut plugins = BTreeMap::new();
for configured in config.plugins { for configured in config.plugins {
let path = configured.path; let path = configured.path;
@@ -658,7 +662,7 @@ impl EditorSession {
&& self.editor.startup_diagnostic().is_none() && self.editor.startup_diagnostic().is_none()
} }
fn apply_update(&mut self, owner: &str, update: PluginUpdate) -> Result<bool, String> { fn apply_update(&mut self, owner: &str, mut update: PluginUpdate) -> Result<bool, String> {
if owner == "core" if owner == "core"
&& let Some(id) = update.request_id && let Some(id) = update.request_id
&& self && self
@@ -668,7 +672,9 @@ impl EditorSession {
.map(|latest| latest.0) .map(|latest| latest.0)
!= Some(id) != Some(id)
{ {
return Ok(false); update
.effects
.retain(|effect| matches!(effect, UpdateEffect::Label { .. }));
} }
for effect in &update.effects { for effect in &update.effects {
if let UpdateEffect::Completion { if let UpdateEffect::Completion {
@@ -697,6 +703,7 @@ impl EditorSession {
} }
// Validate the whole applicable annotation batch before changing any buffer. // Validate the whole applicable annotation batch before changing any buffer.
let validation = (|| -> Result<(), String> { let validation = (|| -> Result<(), String> {
let mut label_bytes = 0usize;
for effect in &update.effects { for effect in &update.effects {
match effect { match effect {
UpdateEffect::Diagnostics { UpdateEffect::Diagnostics {
@@ -757,9 +764,14 @@ impl EditorSession {
} }
UpdateEffect::Completion { .. } => {} UpdateEffect::Completion { .. } => {}
UpdateEffect::Message { .. } => {} UpdateEffect::Message { .. } => {}
UpdateEffect::TablineLabel { text } => { UpdateEffect::Label { name, text } => {
if text.len() > 4 * 1024 * 1024 { crate::editor::validate_label(name, text)?;
return Err("Tabline label exceeds 4 MiB".into()); label_bytes = label_bytes
.checked_add(name.len())
.and_then(|bytes| bytes.checked_add(text.len()))
.ok_or("Labels exceed 4 MiB")?;
if label_bytes > 4 * 1024 * 1024 {
return Err("Labels exceed 4 MiB".into());
} }
} }
} }
@@ -857,8 +869,8 @@ impl EditorSession {
changed |= self.editor.message() != text; changed |= self.editor.message() != text;
self.editor.set_message(text); self.editor.set_message(text);
} }
UpdateEffect::TablineLabel { text } => { UpdateEffect::Label { name, text } => {
changed |= self.editor.set_tabline_label(text); changed |= self.editor.set_label(name, text)?;
} }
} }
} }
@@ -870,7 +882,7 @@ impl EditorSession {
&mut self, &mut self,
owner: &str, owner: &str,
id: u64, id: u64,
effects: Vec<Effect>, mut effects: Vec<Effect>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let source = self let source = self
.requests .requests
@@ -879,16 +891,18 @@ impl EditorSession {
let Some(source) = source else { let Some(source) = source else {
return Err("plugin response has an unknown invocation ID".into()); return Err("plugin response has an unknown invocation ID".into());
}; };
if !self.source_is_current(source) // Document effects retain source freshness; labels belong to the whole session.
|| self.editor.buffer_id() != source.buffer_id let current = self.source_is_current(source)
|| self.editor.pane_id() != source.pane_id && self.editor.buffer_id() == source.buffer_id
{ && self.editor.pane_id() == source.pane_id;
return Ok(false); if !current {
effects.retain(|effect| matches!(effect, Effect::Label { .. }));
} }
if effects if effects
.iter() .iter()
.all(|effect| matches!(effect, Effect::Message(_) | Effect::TablineLabel(_))) .all(|effect| matches!(effect, Effect::Message(_) | Effect::Label { .. }))
{ {
crate::editor::validate_effects(&effects)?;
let mut changed = false; let mut changed = false;
for effect in effects { for effect in effects {
match effect { match effect {
@@ -896,8 +910,8 @@ impl EditorSession {
changed |= self.editor.message() != text; changed |= self.editor.message() != text;
self.editor.set_message(text); self.editor.set_message(text);
} }
Effect::TablineLabel(text) => { Effect::Label { name, text } => {
changed |= self.editor.set_tabline_label(text); changed |= self.editor.set_label(name, text)?;
} }
_ => unreachable!("presentation-only effects checked above"), _ => unreachable!("presentation-only effects checked above"),
} }
@@ -2996,35 +3010,129 @@ end
} }
#[test] #[test]
fn tabline_updates_survive_tab_switches_and_remain_atomic_in_readonly_views() { fn startup_chrome_installs_named_values_without_editing_the_document() {
let fixture = Fixture::new();
fixture.file("buffer.txt", "source");
let session = fixture.open(
r#"
Vamp.configure do |config|
config.tabline = {right: [:battery]}
config.statusline = {center: [:workspace]}
config.labels = {battery: "bat 38%", workspace: "tools\n"}
end
"#,
);
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.label("workspace"), "tools�");
assert_eq!(session.editor.tabline_config().right, ["battery"]);
assert_eq!(session.editor.statusline_config().center, ["workspace"]);
assert_eq!(session.editor.lines(), ["source"]);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
}
#[test]
fn asynchronous_result_labels_outlive_the_source_without_applying_stale_edits() {
let fixture = Fixture::new();
let target = fixture.file("other.txt", "");
let mut session = EditorSession::open(None, vec![], None).unwrap();
let source = InvocationSource {
buffer_id: session.editor.buffer_id(),
pane_id: session.editor.pane_id(),
revision: session.editor.text_revision(),
};
session.editor.execute("tab.new");
session
.editor
.execute(&format!("edit {}", target.display()));
assert_ne!(session.editor.buffer_id(), source.buffer_id);
session.editor.handle(Input::Char('i'));
let message = session.editor.message().to_owned();
for (id, effects) in [
(
1,
vec![
Effect::Insert("stale".into()),
Effect::Message("stale message".into()),
Effect::Label {
name: "battery".into(),
text: "bat 38%".into(),
},
],
),
(
2,
vec![
Effect::Label {
name: "battery".into(),
text: "must not leak".into(),
},
Effect::Label {
name: "bad-name".into(),
text: "invalid".into(),
},
],
),
] {
session
.requests
.entry("fixture".into())
.or_default()
.pending
.insert(id, source);
let result = session.apply_response("fixture", id, effects);
if id == 1 {
assert!(result.unwrap());
} else {
assert!(result.is_err());
}
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.lines(), [""]);
assert_eq!(session.editor.message(), message);
assert_eq!(session.editor.mode(), Mode::Insert);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
}
session.editor.handle(Input::Char('x'));
session.editor.handle(Input::Escape);
session.editor.handle(Input::Char('u'));
assert_eq!(session.editor.lines(), [""]);
assert_eq!(session.editor.label("battery"), "bat 38%");
}
#[test]
fn named_labels_survive_tab_switches_and_remain_atomic_in_readonly_views() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut session = fixture.asynchronous(None); let mut session = fixture.asynchronous(None);
publish( publish(
&mut session, &mut session,
vec![serde_json::json!({ vec![serde_json::json!({
"type": "tabline_label", "text": "bat 37%" "type": "label", "name": "battery", "text": "bat 37%"
})], })],
); );
drain_until(&mut session, |session| { drain_until(&mut session, |session| {
session.editor().tabline_label() == "bat 37%" session.editor().label("battery") == "bat 37%"
}); });
let gate = fixture.0.join("label.gate"); let gate = fixture.0.join("label.gate");
hold( hold(
&mut session, &mut session,
&gate, &gate,
vec![serde_json::json!({ vec![serde_json::json!({
"type": "tabline_label", "text": "bat 38%" "type": "label", "name": "battery", "text": "bat 38%"
})], })],
); );
barrier(&mut session, "label request received"); barrier(&mut session, "label request received");
session.execute("tab.new"); session.execute("tab.new");
release(&mut session, &gate); release(&mut session, &gate);
assert_eq!(session.editor().tabline_label(), "bat 38%"); assert_eq!(session.editor().label("battery"), "bat 38%");
session.execute("tab.previous"); session.execute("tab.previous");
assert_eq!(session.editor().tabline_label(), "bat 38%"); assert_eq!(session.editor().label("battery"), "bat 38%");
let update = |text: &str| PluginUpdate { let update = |text: &str| PluginUpdate {
request_id: None, request_id: None,
effects: vec![UpdateEffect::TablineLabel { text: text.into() }], effects: vec![UpdateEffect::Label {
name: "battery".into(),
text: text.into(),
}],
}; };
assert!( assert!(
!session !session
@@ -3034,7 +3142,8 @@ end
let rejected = PluginUpdate { let rejected = PluginUpdate {
request_id: None, request_id: None,
effects: vec![ effects: vec![
UpdateEffect::TablineLabel { UpdateEffect::Label {
name: "battery".into(),
text: "must not leak".into(), text: "must not leak".into(),
}, },
UpdateEffect::SemanticTokens { UpdateEffect::SemanticTokens {
@@ -3045,7 +3154,48 @@ end
], ],
}; };
assert!(session.apply_update("generic_fixture", rejected).is_err()); assert!(session.apply_update("generic_fixture", rejected).is_err());
assert_eq!(session.editor().tabline_label(), "bat 38%"); assert_eq!(session.editor().label("battery"), "bat 38%");
let message = session.editor.message().to_owned();
for effects in [
vec![
UpdateEffect::Message {
text: "must not leak".into(),
},
UpdateEffect::Label {
name: "battery".into(),
text: "changed".into(),
},
UpdateEffect::Label {
name: "bad-name".into(),
text: "invalid".into(),
},
],
vec![
UpdateEffect::Label {
name: "battery".into(),
text: "x".repeat(2 * 1024 * 1024),
},
UpdateEffect::Label {
name: "workspace".into(),
text: "x".repeat(2 * 1024 * 1024),
},
],
] {
assert!(
session
.apply_update(
"generic_fixture",
PluginUpdate {
request_id: None,
effects
}
)
.is_err()
);
assert_eq!(session.editor.label("battery"), "bat 38%");
assert_eq!(session.editor.label("workspace"), "");
assert_eq!(session.editor.message(), message);
}
session.editor.execute("diff"); session.editor.execute("diff");
assert!(session.editor.mutation_blocked()); assert!(session.editor.mutation_blocked());
assert!( assert!(
@@ -3053,10 +3203,10 @@ end
.apply_update("generic_fixture", update("bat\t39%\n")) .apply_update("generic_fixture", update("bat\t39%\n"))
.unwrap() .unwrap()
); );
assert_eq!(session.editor().tabline_label(), "bat�39%�"); assert_eq!(session.editor().label("battery"), "bat�39%�");
assert_eq!(session.editor().lines(), [""]); assert_eq!(session.editor().lines(), [""]);
assert!(session.apply_update("generic_fixture", update("")).unwrap()); assert!(session.apply_update("generic_fixture", update("")).unwrap());
assert_eq!(session.editor().tabline_label(), ""); assert_eq!(session.editor().label("battery"), "");
} }
#[test] #[test]
+713 -187
View File
File diff suppressed because it is too large Load Diff