diff --git a/docs/architecture.md b/docs/architecture.md index 7199360..8d7f529 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +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: - `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 top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, and named-value validation. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks. +- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, named-value validation, and typed command/search popup anchors. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks. - `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured. - `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. @@ -41,6 +41,7 @@ Editor-owned jump history is a bounded 100-location `VecDeque` of buffer IDs and Frontends inspect `buffer_id()`, `buffer_count()`, `has_buffer(id)`, and the ascending-ID `buffers()` iterator of borrowed path/dirty/active metadata. Existing text, cursor, selection, search, and mode methods describe only the active buffer. Frontends inspect `tab_id()` for stable identity, `tab_index()` for display order, `tab_count()`, and `tab_buffer(index)` for borrowed title metadata. `pane_layout()` describes only the active tab; globally unique pane IDs keep inactive tabs' viewports alive. Documents remain unique across all tabs, while independent pane/buffer views are parked and restored without copying text. Frontends borrow `top_bar_config()` and `bottom_bar_config()` for validated ordered left/center/right segment groups and effective segment styles, and `label(name)` for session-wide custom values. The TUI owns terminal-cell widths, full-row center anchoring, clipping, and accent styling. Label effects carry only a name and plain text; they are global presentation updates, not buffer/revision-bound results or render callbacks. +Frontends borrow `popup_config()` for independently configured command/search anchors. The TUI resolves each anchor against the chrome-free workspace using the existing bounded popup size; startup configuration does not alter buffer text, modal input, or notification positioning. Frontends borrow `lsp_label()` / `lsp_names()` for the focused buffer's cached ready-server metadata. Initialization-only worker events trigger status redraws independently of diagnostics; session synchronization clears or replaces names when focus changes or a server retires. `notification_visible()` gates persistent operation/diagnostic popups independently of stored message text, so Escape dismissal neither alters text nor collapses carets. Display-only home abbreviation does not change document identity. Frontends inspect `file_picker()` for query, presentation, view, explorer-local INSERT/filter subfocus, selection, and errors, and iterate borrowed `FileRow` metadata (actual path, display label, directory flag, visible depth, expansion). Dock side is a logical preference. They allocate rectangles, add folder suffixes/colors, and render; hierarchy, filtering, parent/child navigation, focus transitions, and selected-file opening remain backend-owned. diff --git a/docs/configuration.md b/docs/configuration.md index 5877bcb..4c893f5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -89,6 +89,23 @@ Any other segment name is a custom named value, such as `:build_status`. Set ini Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks. +### Command and search popup anchors + +Configure the two input windows independently: + +```ruby +Vamp.configure do |config| + config.popup.command = { anchor: :top_right } + config.popup.search = { anchor: :center } +end +``` + +Both default to `:bottom_left`. Supported anchors are `:top_left`, `:top_center`, `:top_right`, `:center_left`, `:center`, `:center_right`, `:bottom_left`, `:bottom_center`, and `:bottom_right`. String values and string hash keys are also accepted. + +Anchors refer to the chrome-free workspace, not the whole terminal or focused pane. Center positions use the full workspace width/height. The windows remain bounded to 72 columns by 3 rows, preserving titles, internal padding, cursor clipping, and modal behavior. This setting does not move revision selectors or the operation notification. + +Only the `anchor` option is supported; unknown anchors/options, callbacks, nulls, and wrong types reject configuration. Omitting a window or its anchor keeps its default. Use the two setters separately; a combined `config.popup = { ... }` assignment is not supported. + ## Actions and modes `config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings, canonical Ex names (such as `"quit"`), shorthand aliases (such as `"q"`), and mode strings are not accepted. Only plugin-owned commands use exact string names (`"greet"`, without `:` or arguments) and can only be bound in Normal mode. Interactive Ex commands remain textual. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. diff --git a/docs/editing.md b/docs/editing.md index 7d0653f..5f8b482 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -142,7 +142,7 @@ Submitting an empty search repeats the last query. Esc cancels without moving th ## Commands, saving, and pasted input -`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Input anchors to the workspace's bottom-left edge, directly above the bottom bar when visible, and temporarily hides operation notifications; there is no permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable. +`:` opens compact floating command input titled **Command**; `/` opens input titled **Search** for buffer text search. Titles sit at the top-left border, replacing the displayed leading `:` / `/`. Both input fields have one column of padding on each side. Enter submits and Escape cancels using the existing modal rules. Long Unicode input scrolls horizontally without changing buffer text. Both windows default to the workspace's bottom-left edge, directly above the bottom bar when visible; their [anchors can be configured independently](configuration.md#command-and-search-popup-anchors). They temporarily hide operation notifications and never reserve a permanent prompt/footer row. Tiny windows omit borders/titles or padding when needed to keep input usable. The latest reported operation result appears in a non-focusable popup at the workspace's bottom-right, just above the bottom bar when visible, with one column of inner padding on each side when space permits. It remains until dismissed or replaced, with no timer. In Normal mode, the first Escape dismisses it without collapsing carets; a subsequent Escape retains ordinary Normal behavior. Existing modal windows, focused explorers/diff selectors, and pending prefixes retain Escape priority. New operation messages reopen the popup; routine successful buffer switches do not duplicate the buffer/path information already shown in the bar. diff --git a/docs/plugins.md b/docs/plugins.md index 8070462..511fc07 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -72,7 +72,7 @@ Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characte ## 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, bindings, `top_bar` and `bottom_bar` chrome maps, 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. +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, `top_bar` and `bottom_bar` chrome maps, `popup` anchor options, 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`. diff --git a/examples/config.rb b/examples/config.rb index 0618259..66a8cc3 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -7,6 +7,8 @@ Vamp.configure do |config| config.file_explorer_side = "right" config.file_explorer_view = "tree" config.file_picker_view = "tree" + # config.popup.command = { anchor: :bottom_left } + # config.popup.search = { anchor: :center } # Status above, tabs and live battery below; either bar accepts all segments. config.bar.top = { left: [:mode, :path, :diagnostics, :selections, :count, :pending, :buffer], diff --git a/src/chrome.rs b/src/chrome.rs index 54c1160..988f766 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -1,6 +1,86 @@ use serde::{Deserialize, Deserializer}; use std::collections::BTreeMap; +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum PopupAnchor { + TopLeft, + TopCenter, + TopRight, + CenterLeft, + Center, + CenterRight, + #[default] + BottomLeft, + BottomCenter, + BottomRight, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PopupOptions { + pub anchor: PopupAnchor, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PopupConfig { + pub command: PopupOptions, + pub search: PopupOptions, +} + +impl<'de> Deserialize<'de> for PopupOptions { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Options { + #[serde(default)] + anchor: PopupAnchor, + } + let options: Options = deserialize_object(deserializer)?; + Ok(Self { + anchor: options.anchor, + }) + } +} + +impl<'de> Deserialize<'de> for PopupConfig { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Options { + #[serde(default)] + command: PopupOptions, + #[serde(default)] + search: PopupOptions, + } + let options: Options = deserialize_object(deserializer)?; + Ok(Self { + command: options.command, + search: options.search, + }) + } +} + +// Struct derives also accept sequences; popup configuration accepts only objects. +fn deserialize_object<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + deserializer: D, +) -> Result { + struct ObjectVisitor(std::marker::PhantomData); + + impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor { + type Value = T; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a popup configuration object") + } + + fn visit_map>(self, map: M) -> Result { + T::deserialize(serde::de::value::MapAccessDeserializer::new(map)) + } + } + + deserializer.deserialize_map(ObjectVisitor(std::marker::PhantomData)) +} + const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[ "mode", "pending", @@ -260,6 +340,60 @@ pub fn labels<'de, D: Deserializer<'de>>( mod tests { use super::*; + #[test] + fn popup_partial_options_keep_command_and_search_independent() { + let popup: PopupConfig = serde_json::from_value(serde_json::json!({ + "command": {"anchor": "top_right"} + })) + .unwrap(); + assert_eq!(popup.command.anchor, PopupAnchor::TopRight); + assert_eq!(popup.search.anchor, PopupAnchor::BottomLeft); + let popup: PopupConfig = serde_json::from_value(serde_json::json!({ + "command": {}, "search": {"anchor": "center_left"} + })) + .unwrap(); + assert_eq!(popup.command.anchor, PopupAnchor::BottomLeft); + assert_eq!(popup.search.anchor, PopupAnchor::CenterLeft); + } + + #[test] + fn malformed_popup_options_are_rejected_at_the_boundary() { + for value in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!(7), + serde_json::json!({"selector": {}}), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{value}" + ); + } + for options in [ + serde_json::json!(null), + serde_json::json!([]), + serde_json::json!("center"), + serde_json::json!({"anchor": null}), + serde_json::json!({"anchor": 7}), + serde_json::json!({"anchor": true}), + serde_json::json!({"anchor": []}), + serde_json::json!({"anchor": {}}), + serde_json::json!({"anchor": "middle"}), + serde_json::json!({"anchor": "TopLeft"}), + serde_json::json!({"width": 72}), + serde_json::json!({"anchor": "center", "callback": "run"}), + ] { + for mode in ["command", "search"] { + let mut popup = serde_json::json!({}); + popup[mode] = options.clone(); + assert!( + serde_json::from_value::(popup).is_err(), + "{mode}: {options}" + ); + } + } + } + #[derive(Deserialize)] struct Lines { #[serde(deserialize_with = "top")] diff --git a/src/editor.rs b/src/editor.rs index 40ca3fc..f853006 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,4 +1,4 @@ -use crate::chrome::{ChromeLine, validate_name}; +use crate::chrome::{ChromeLine, PopupConfig, validate_name}; use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; @@ -1354,6 +1354,7 @@ pub struct Editor { notification_visible: bool, top_bar: ChromeLine, bottom_bar: ChromeLine, + popup: PopupConfig, labels: BTreeMap, battery_charge: Option, battery_label: String, @@ -1447,6 +1448,7 @@ impl Editor { notification_visible: true, top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), + popup: PopupConfig::default(), labels: BTreeMap::new(), battery_charge: None, battery_label: String::new(), @@ -3846,6 +3848,12 @@ impl Editor { pub fn bottom_bar_config(&self) -> &ChromeLine { &self.bottom_bar } + pub fn popup_config(&self) -> &PopupConfig { + &self.popup + } + pub fn set_popup_config(&mut self, popup: PopupConfig) { + self.popup = popup; + } pub fn label(&self, name: &str) -> &str { self.labels.get(name).map_or("", String::as_str) } diff --git a/src/ruby.rs b/src/ruby.rs index b2befc9..785d59c 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -1,4 +1,4 @@ -use crate::chrome::ChromeLine; +use crate::chrome::{ChromeLine, PopupConfig}; use crate::editor::{Context, Effect, Feature}; use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect}; use serde::{Deserialize, Serialize}; @@ -40,6 +40,7 @@ pub struct Config { pub clipboard_mode: String, pub top_bar: ChromeLine, pub bottom_bar: ChromeLine, + pub popup: PopupConfig, pub labels: BTreeMap, pub diff_context_lines: usize, pub diff_expand_lines: usize, @@ -79,6 +80,7 @@ impl Default for Config { clipboard_mode: "system".into(), top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), + popup: PopupConfig::default(), labels: BTreeMap::new(), diff_context_lines: 10, diff_expand_lines: 15, @@ -104,6 +106,7 @@ pub fn load_config(path: &Path) -> Result { clipboard_mode, top_bar, bottom_bar, + popup, labels, diff_context_lines, diff_expand_lines, @@ -131,6 +134,7 @@ pub fn load_config(path: &Path) -> Result { config.clipboard_mode = clipboard_mode; config.top_bar = top_bar; config.bottom_bar = bottom_bar; + config.popup = popup; config.labels = labels; config.diff_context_lines = diff_context_lines; config.diff_expand_lines = diff_expand_lines; @@ -454,6 +458,7 @@ enum Response { top_bar: ChromeLine, #[serde(deserialize_with = "crate::chrome::bottom")] bottom_bar: ChromeLine, + popup: PopupConfig, #[serde(deserialize_with = "crate::chrome::labels")] labels: BTreeMap, #[serde(deserialize_with = "diff_context_lines")] @@ -1183,9 +1188,9 @@ end assert!(load_config(&path).unwrap_err().contains(error), "{value}"); } 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","top_bar":{},"bottom_bar":{},"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","top_bar":{},"bottom_bar":{},"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","top_bar":{},"bottom_bar":{},"labels":{},"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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"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","top_bar":{},"bottom_bar":{},"popup":{"command":{},"search":{}},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#, ] { assert!(serde_json::from_str::(frame).is_err(), "{frame}"); } @@ -1226,6 +1231,75 @@ end assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}"); } + #[test] + fn popup_configuration_accepts_ruby_symbol_and_string_anchors_independently() { + use crate::chrome::PopupAnchor; + + let fixture = Fixture::new(); + let anchors = [ + ("top_left", PopupAnchor::TopLeft), + ("top_center", PopupAnchor::TopCenter), + ("top_right", PopupAnchor::TopRight), + ("center_left", PopupAnchor::CenterLeft), + ("center", PopupAnchor::Center), + ("center_right", PopupAnchor::CenterRight), + ("bottom_left", PopupAnchor::BottomLeft), + ("bottom_center", PopupAnchor::BottomCenter), + ("bottom_right", PopupAnchor::BottomRight), + ]; + for (index, (command_name, command_anchor)) in anchors.iter().enumerate() { + let (search_name, search_anchor) = anchors[(index + 1) % anchors.len()]; + let path = fixture.file( + "config.rb", + &format!( + "Vamp.configure do |config|\n\ + config.popup.command = {{anchor: :{command_name}}}\n\ + config.popup.search = {{'anchor' => '{search_name}'}}\n\ + end" + ), + ); + let loaded = load_config(&path).unwrap(); + assert_eq!(loaded.popup.command.anchor, *command_anchor); + assert_eq!(loaded.popup.search.anchor, search_anchor); + } + } + + #[test] + fn popup_configuration_rejects_unknown_options_anchors_callbacks_and_types() { + let fixture = Fixture::new(); + for (value, expected) in [ + ("[]", "must be a Hash"), + ("nil", "must be a Hash"), + ("7", "must be a Hash"), + ("{width: 72}", "unknown popup option"), + ("{callback: -> { 'center' }}", "unknown popup option"), + ("{anchor: :middle}", "unknown popup anchor"), + ("{anchor: 'TopLeft'}", "unknown popup anchor"), + ("{anchor: nil}", "must be a String"), + ("{anchor: 7}", "must be a String"), + ("{anchor: true}", "must be a String"), + ("{anchor: []}", "must be a String"), + ("{anchor: {}}", "must be a String"), + ("{anchor: -> { :center }}", "must be a String"), + ( + "{anchor: :center, 'anchor' => :top_left}", + "duplicate chrome key", + ), + ("{7 => :center}", "chrome key must be a String"), + ] { + for mode in ["command", "search"] { + let path = fixture.file( + "config.rb", + &format!("Vamp.configure {{ |config| config.popup.{mode} = {value} }}"), + ); + let error = load_config(&path).unwrap_err(); + assert!(error.contains(expected), "{mode}={value}: {error}"); + } + } + let path = fixture.file("config.rb", "Vamp.configure { |config| config.popup = {} }"); + assert!(load_config(&path).unwrap_err().contains("undefined method")); + } + #[test] fn bar_namespace_is_stable_and_accepts_swapped_builtins() { let fixture = Fixture::new(); @@ -1334,6 +1408,7 @@ end "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "top_bar": {}, "bottom_bar": {}, "labels": {}, + "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7 }); for setting in ["diff_context_lines", "diff_expand_lines"] { @@ -1747,6 +1822,7 @@ end"#, "bindings": [], "leader": "space", "file_explorer_side": "right", "file_picker_view": "tree", "file_explorer_view": "tree", "top_bar": {}, "bottom_bar": {}, "labels": {}, + "popup": {"command": {}, "search": {}}, "clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15 }); let Response::Config { lsp, .. } = @@ -1832,6 +1908,15 @@ end"#, "frame.delete('top_bar')", "frame.delete('bottom_bar')", "frame.delete('labels')", + "frame.delete('popup')", + "frame['popup'] = nil", + "frame['popup'] = []", + "frame['popup'] = {'selector' => {}}", + "frame['popup'] = {'command' => nil}", + "frame['popup'] = {'search' => 7}", + "frame['popup'] = {'command' => {'anchor' => nil}}", + "frame['popup'] = {'search' => {'anchor' => 'middle'}}", + "frame['popup'] = {'command' => {'width' => 72}}", "frame['top_bar'] = 7", "frame['bottom_bar'] = []", "frame['labels'] = {'battery' => 7}", diff --git a/src/ruby_config.rb b/src/ruby_config.rb index 301d4ac..43cf373 100644 --- a/src/ruby_config.rb +++ b/src/ruby_config.rb @@ -75,6 +75,46 @@ module Vamp end end + # Collects anchor options for the independently positioned command and search popups. + class PopupConfig + # @return [Hash{String => String}] + attr_reader :command, :search + + # @return [void] + def initialize + @command = {} + @search = {} + end + + # @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid + # @return [Hash{String => String}] + def command=(options) + @command = options!(options) + end + + # @param options [Hash{String, Symbol => String, Symbol}] anchor in the workspace's 3x3 grid + # @return [Hash{String => String}] + def search=(options) + @search = options!(options) + end + + private + + # @param options [Hash{String, Symbol => String, Symbol}] + # @return [Hash{String => String}] + def options!(options) + Vamp.chrome_hash!(options).to_h do |option, value| + raise ArgumentError, "unknown popup option: #{option}" unless option == "anchor" + value = value.to_s if value.is_a?(Symbol) + value = Vamp.string!(value, "popup anchor") + unless %w[top_left top_center top_right center_left center center_right bottom_left bottom_center bottom_right].include?(value) + raise ArgumentError, "unknown popup anchor: #{value}" + end + [option, value] + end + end + end + # Collects and validates the Ruby startup configuration DSL. class Config # @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options @@ -83,6 +123,9 @@ module Vamp # @return [Vamp::BarConfig] attr_reader :bar + # @return [Vamp::PopupConfig] + attr_reader :popup + # @return [void] def initialize @plugins = [] @@ -94,6 +137,7 @@ module Vamp @file_explorer_view = "tree" @clipboard_mode = "system" @bar = BarConfig.new + @popup = PopupConfig.new @labels = {} @diff_context_lines = 10 @diff_expand_lines = 15 @@ -193,6 +237,7 @@ module Vamp "leader" => @leader, "file_explorer_side" => @file_explorer_side, "file_picker_view" => @file_picker_view, "file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode, "top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels, + "popup" => { "command" => @popup.command, "search" => @popup.search }, "diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines } end diff --git a/src/session.rs b/src/session.rs index ac9335e..c808c73 100644 --- a/src/session.rs +++ b/src/session.rs @@ -227,6 +227,7 @@ impl EditorSession { { notices.push(format!("chrome: {error}")); } + editor.set_popup_config(config.popup); let mut plugins = BTreeMap::new(); for configured in config.plugins { let path = configured.path; diff --git a/src/tui.rs b/src/tui.rs index 353c240..22dba0d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -33,7 +33,7 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use crate::{ - chrome::{ChromeLine, SegmentConfig}, + chrome::{ChromeLine, PopupAnchor, SegmentConfig}, diff::{ChangedFile, DiffState, DisplayRow, FileStatus, Focus, PaneLayout, Phase, RowKind}, editor::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, @@ -1840,7 +1840,32 @@ fn draw_floating_input(frame: &mut Frame, editor: &Editor, area: Rect) -> Option } let width = area.width.min(72); let height = area.height.min(3); - let panel = Rect::new(area.x, area.bottom() - height, width, height); + let anchor = if editor.mode() == Mode::Search { + editor.popup_config().search.anchor + } else { + editor.popup_config().command.anchor + }; + let x = area.x + + match anchor { + PopupAnchor::TopLeft | PopupAnchor::CenterLeft | PopupAnchor::BottomLeft => 0, + PopupAnchor::TopCenter | PopupAnchor::Center | PopupAnchor::BottomCenter => { + (area.width - width) / 2 + } + PopupAnchor::TopRight | PopupAnchor::CenterRight | PopupAnchor::BottomRight => { + area.width - width + } + }; + let y = area.y + + match anchor { + PopupAnchor::TopLeft | PopupAnchor::TopCenter | PopupAnchor::TopRight => 0, + PopupAnchor::CenterLeft | PopupAnchor::Center | PopupAnchor::CenterRight => { + (area.height - height) / 2 + } + PopupAnchor::BottomLeft | PopupAnchor::BottomCenter | PopupAnchor::BottomRight => { + area.height - height + } + }; + let panel = Rect::new(x, y, width, height); let bordered = panel.width >= 4 && panel.height >= 3; let padding = u16::from(panel.width.saturating_sub(2 * u16::from(bordered)) >= 3); let mut block = Block::default() @@ -5638,6 +5663,118 @@ mod tests { assert_eq!(panel.bottom(), 12); } + #[test] + fn configured_floating_inputs_paint_each_anchor_independently_inside_the_workspace() { + let anchors = [ + (PopupAnchor::TopLeft, 0, 0), + (PopupAnchor::TopCenter, 14, 0), + (PopupAnchor::TopRight, 29, 0), + (PopupAnchor::CenterLeft, 0, 5), + (PopupAnchor::Center, 14, 5), + (PopupAnchor::CenterRight, 29, 5), + (PopupAnchor::BottomLeft, 0, 10), + (PopupAnchor::BottomCenter, 14, 10), + (PopupAnchor::BottomRight, 29, 10), + ]; + for (index, &(command_anchor, command_x, command_y)) in anchors.iter().enumerate() { + let (search_anchor, search_x, search_y) = anchors[(index + 1) % anchors.len()]; + for (prefix, title, x, y) in [ + (':', "Command", command_x, command_y), + ('/', "Search", search_x, search_y), + ] { + let mut editor = Editor::open(None, vec![]).unwrap(); + let mut popup = *editor.popup_config(); + popup.command.anchor = command_anchor; + popup.search.anchor = search_anchor; + editor.set_popup_config(popup); + editor.set_message("hidden notification"); + editor.handle(Input::Char(prefix)); + for ch in "needle".chars() { + editor.handle(Input::Char(ch)); + } + for (width, height) in [ + (101, 13), + (12, 3), + (3, 2), + (2, 1), + (1, 1), + (0, 1), + (1, 0), + (0, 0), + ] { + let workspace = Rect::new(5, 4, width, height); + let mut terminal = + Terminal::new(ratatui::backend::TestBackend::new(120, 24)).unwrap(); + let mut panels = WheelPanels::default(); + terminal + .draw(|frame| { + frame.render_widget( + Paragraph::new(vec![Line::from(".".repeat(120)); 24]), + frame.area(), + ); + draw_overlays(frame, &editor, workspace, None, &mut panels); + }) + .unwrap(); + assert!(panels.notification.is_none()); + if width == 0 || height == 0 { + assert!(panels.input.is_none()); + assert_eq!( + terminal.backend().buffer()[(workspace.x, workspace.y)].symbol(), + "." + ); + continue; + } + let expected = if width == 101 { + Rect::new(workspace.x + x, workspace.y + y, 72, 3) + } else { + workspace + }; + let panel = panels.input.unwrap(); + assert_eq!( + panel, expected, + "{prefix} {command_anchor:?} {search_anchor:?}" + ); + assert_eq!(panel.intersection(workspace), panel); + let cursor = terminal.get_cursor_position().unwrap(); + assert!(panel.contains(cursor)); + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(0, 0)].symbol(), "."); + assert_eq!(buffer[(cursor.x, cursor.y)].symbol(), " "); + if width >= 12 { + assert_eq!(buffer[(panel.x, panel.y)].symbol(), "┌"); + assert_eq!(buffer[(panel.right() - 1, panel.y)].symbol(), "┐"); + assert_eq!(buffer[(panel.x, panel.bottom() - 1)].symbol(), "└"); + assert_eq!( + buffer[(panel.right() - 1, panel.bottom() - 1)].symbol(), + "┘" + ); + assert!( + pane_screen(&terminal, Rect::new(panel.x, panel.y, panel.width, 1)) + .contains(title) + ); + assert_eq!(buffer[(panel.x + 1, panel.y + 1)].symbol(), " "); + assert_eq!(buffer[(panel.x + 2, panel.y + 1)].symbol(), "n"); + assert_eq!( + pane_screen( + &terminal, + Rect::new(panel.x + 1, panel.y + 1, panel.width - 2, 1) + ) + .trim(), + "needle" + ); + assert_eq!(cursor, Position::new(panel.x + 8, panel.y + 1)); + } else { + let cursor_x = u16::from(width > 1); + assert_eq!(cursor, Position::new(panel.x + cursor_x, panel.y)); + if width == 2 { + assert_eq!(buffer[(panel.x, panel.y)].symbol(), "e"); + } + } + } + } + } + } + #[test] fn floating_inputs_share_workspace_geometry_across_documents_splits_and_diffs() { for context in ["document", "split", "diff", "split-diff", "selector"] {