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
+131 -40
View File
@@ -1,3 +1,4 @@
use crate::chrome::ChromeLine;
use crate::editor::{Context, Effect, Feature};
use crate::plugin::{DocumentEvent, PluginEvent, PluginUpdate, UpdateEffect};
use serde::{Deserialize, Serialize};
@@ -8,7 +9,10 @@ use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender};
use std::sync::{Arc, OnceLock};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use std::{collections::HashSet, env};
use std::{
collections::{BTreeMap, HashSet},
env,
};
const RUNNER: &str = concat!(
include_str!("ruby_values.rb"),
@@ -34,7 +38,9 @@ pub struct Config {
pub file_picker_view: String,
pub file_explorer_view: 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_expand_lines: usize,
}
@@ -71,7 +77,9 @@ impl Default for Config {
file_picker_view: "tree".into(),
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
tabline_label: String::new(),
tabline: ChromeLine::tabline(),
statusline: ChromeLine::statusline(),
labels: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
}
@@ -94,7 +102,9 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view,
file_explorer_view,
clipboard_mode,
tabline_label,
tabline,
statusline,
labels,
diff_context_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_explorer_view = file_explorer_view;
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_expand_lines = diff_expand_lines;
Ok(config)
@@ -350,7 +362,7 @@ fn convert_effects(effects: Vec<WireEffect>) -> Vec<Effect> {
WireEffect::Insert { text } => Effect::Insert(text),
WireEffect::Replace { text } => Effect::Replace(text),
WireEffect::Message { text } => Effect::Message(text),
WireEffect::TablineLabel { text } => Effect::TablineLabel(text),
WireEffect::Label { name, text } => Effect::Label { name, text },
})
.collect()
}
@@ -438,7 +450,12 @@ enum Response {
file_picker_view: String,
file_explorer_view: 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")]
diff_context_lines: usize,
#[serde(deserialize_with = "diff_expand_lines")]
@@ -500,8 +517,8 @@ enum WireEffect {
Replace { text: String },
#[serde(rename = "message")]
Message { text: String },
#[serde(rename = "tabline_label")]
TablineLabel { text: String },
#[serde(rename = "label")]
Label { name: String, text: String },
}
struct OutboundFrame {
@@ -890,8 +907,8 @@ Vamp.command "greet" do |editor, args|
editor.insert "Hello #{args}!"
editor.replace editor.text
editor.message "Greeting inserted"
editor.tabline_label "界 e\u0301 👩‍🚀\t\n\e"
editor.tabline_label ""
editor.label :battery, "界 e\u0301 👩‍🚀\t\n\e"
editor.label "battery", ""
end
"#);
assert_eq!(feature.name, "greeting");
@@ -902,8 +919,9 @@ end
for _ in 0..2 {
let effects = plugin.invoke("greet", "friend", &context()).unwrap();
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"
&& name == "battery" && cleared_name == "battery"
&& 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}");
}
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":[],"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":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":[],"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":{},"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":{},"statusline":{},"labels":{},"diff_context_lines":10,"diff_expand_lines":15}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
}
#[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();
for (value, expected) in [
(r#""""#, ""),
(r#""界 e\u0301 👩‍🚀\t\n\e""#, "界 e\u{301} 👩‍🚀\t\n\u{1b}"),
let path = fixture.file(
"config.rb",
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(
"config.rb",
&format!("Vamp.configure {{ |config| config.tabline_label = {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} }}"),
&format!("Vamp.configure {{ |config| config.{setting} = {value} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(
error.contains("tabline label must be a String"),
"{value}: {error}"
);
assert!(error.contains(expected), "{setting}={value}: {error}");
}
}
#[test]
fn tabline_label_wire_effects_require_string_text() {
fn label_wire_effects_require_name_and_string_text() {
for frame in [
serde_json::json!({"type": "tabline_label"}),
serde_json::json!({"type": "tabline_label", "text": null}),
serde_json::json!({"type": "tabline_label", "text": 7}),
serde_json::json!({"type": "tabline_label", "text": "", "extra": true}),
serde_json::json!({"type": "label", "text": ""}),
serde_json::json!({"type": "label", "name": 7, "text": ""}),
serde_json::json!({"type": "label", "name": "battery"}),
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::<UpdateEffect>(frame).is_err());
@@ -1230,7 +1310,7 @@ end
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"tabline_label": "",
"tabline": {}, "statusline": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
@@ -1643,7 +1723,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"tabline_label": "",
"tabline": {}, "statusline": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
});
let Response::Config { lsp, .. } =
@@ -1726,8 +1806,19 @@ end"#,
for mutation in [
"return '['",
"frame.delete('lsp')",
"frame.delete('tabline_label')",
"frame['tabline_label'] = 7",
"frame.delete('tabline')",
"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['unexpected'] = true",
] {