feat: configure command and search popup anchors

This commit is contained in:
Valdemar Lorenzen
2026-10-12 13:41:45 +02:00
parent 406f45c6fb
commit 6eb99a792a
11 changed files with 440 additions and 10 deletions
+134
View File
@@ -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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[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<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[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<T, D::Error> {
struct ObjectVisitor<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for ObjectVisitor<T> {
type Value = T;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a popup configuration object")
}
fn visit_map<M: serde::de::MapAccess<'de>>(self, map: M) -> Result<T, M::Error> {
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::<PopupConfig>(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::<PopupConfig>(popup).is_err(),
"{mode}: {options}"
);
}
}
}
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "top")]
+9 -1
View File
@@ -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<String, String>,
battery_charge: Option<u8>,
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)
}
+89 -4
View File
@@ -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<String, String>,
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<Config, String> {
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, String> {
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<String, String>,
#[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::<Response>(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}",
+45
View File
@@ -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
+1
View File
@@ -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;
+139 -2
View File
@@ -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"] {