feat: configure interchangeable top and bottom bars

This commit is contained in:
Valdemar Lorenzen
2026-10-12 11:47:31 +02:00
parent 4cfd068f1c
commit 0d3fbc01f8
12 changed files with 776 additions and 340 deletions
+61 -32
View File
@@ -1,7 +1,7 @@
use serde::{Deserialize, Deserializer};
use std::collections::BTreeMap;
const STATUS_SEGMENTS: &[&str] = &[
const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[
"mode",
"pending",
"buffer",
@@ -43,7 +43,7 @@ impl Default for SegmentConfig {
}
impl ChromeLine {
pub fn tabline() -> Self {
pub fn top() -> Self {
Self {
left: vec!["tabs".into()],
center: Vec::new(),
@@ -52,15 +52,15 @@ impl ChromeLine {
}
}
pub fn statusline() -> Self {
pub fn bottom() -> Self {
Self {
left: STATUS_SEGMENTS[..STATUS_SEGMENTS.len() - 1]
left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 1]
.iter()
.map(|name| (*name).into())
.collect(),
center: Vec::new(),
right: vec!["position".into()],
segments: STATUS_SEGMENTS
segments: DEFAULT_BOTTOM_SEGMENTS
.iter()
.map(|name| ((*name).into(), default_segment(name)))
.collect(),
@@ -72,7 +72,7 @@ impl ChromeLine {
self.segments.get(name).expect("validated chrome segment")
}
pub fn validate(&mut self, tabline: bool) -> Result<(), String> {
pub fn validate(&mut self) -> Result<(), String> {
for name in self
.left
.iter()
@@ -81,13 +81,6 @@ impl ChromeLine {
.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 {
@@ -188,7 +181,7 @@ struct SegmentOptions {
fn deserialize_line<'de, D: Deserializer<'de>>(
deserializer: D,
tabline: bool,
mut line: ChromeLine,
) -> 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)?;
@@ -207,11 +200,6 @@ fn deserialize_line<'de, D: Deserializer<'de>>(
}
}
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;
}
@@ -242,16 +230,16 @@ fn deserialize_line<'de, D: Deserializer<'de>>(
segment.first_leading_edge = Some(edge);
}
}
line.validate(tabline).map_err(serde::de::Error::custom)?;
line.validate().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 top<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, ChromeLine::top())
}
pub fn statusline<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, false)
pub fn bottom<'de, D: Deserializer<'de>>(deserializer: D) -> Result<ChromeLine, D::Error> {
deserialize_line(deserializer, ChromeLine::bottom())
}
pub fn labels<'de, D: Deserializer<'de>>(
@@ -270,9 +258,9 @@ mod tests {
#[derive(Deserialize)]
struct Lines {
#[serde(deserialize_with = "tabline")]
#[serde(deserialize_with = "top")]
top: ChromeLine,
#[serde(deserialize_with = "statusline")]
#[serde(deserialize_with = "bottom")]
bottom: ChromeLine,
}
@@ -290,11 +278,51 @@ mod tests {
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
}
#[test]
fn both_positions_accept_all_builtins_and_custom_styles() {
let names = [
"tabs",
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count",
"language",
"position",
"workspace",
];
let options = serde_json::json!({
"left": names,
"center": ["language"],
"right": ["tabs"],
"tabs": {"title": "{index}:{name}:{path}{modified}", "padding": 0},
"mode": {"leading_edge": "<", "trailing_edge": ">"},
"workspace": {"first_leading_edge": ""}
});
let lines: Lines = serde_json::from_value(serde_json::json!({
"top": options,
"bottom": options,
}))
.unwrap();
for line in [&lines.top, &lines.bottom] {
assert_eq!(line.left, names);
assert_eq!(line.center, ["language"]);
assert_eq!(line.right, ["tabs"]);
assert_eq!(line.segment("tabs").padding, 0);
assert_eq!(line.segment("mode").leading_edge, "<");
assert_eq!(
line.segment("workspace").first_leading_edge.as_deref(),
Some("")
);
}
}
#[test]
fn malformed_chrome_is_rejected_at_the_boundary() {
for top in [
for options 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}}),
@@ -304,10 +332,11 @@ mod tests {
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()
);
for position in ["top", "bottom"] {
let mut frame = serde_json::json!({"top": {}, "bottom": {}});
frame[position] = options.clone();
assert!(serde_json::from_value::<Lines>(frame).is_err());
}
}
}
}
+44 -26
View File
@@ -1351,8 +1351,8 @@ pub struct Editor {
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
last_edit: Option<EditRecipe>,
message: String,
tabline: ChromeLine,
statusline: ChromeLine,
top_bar: ChromeLine,
bottom_bar: ChromeLine,
labels: BTreeMap<String, String>,
quit: bool,
capabilities: Vec<String>,
@@ -1439,8 +1439,8 @@ impl Editor {
owned_system_clipboard: None,
last_edit: None,
message: String::new(),
tabline: ChromeLine::tabline(),
statusline: ChromeLine::statusline(),
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
labels: BTreeMap::new(),
quit: false,
capabilities,
@@ -3831,11 +3831,11 @@ impl Editor {
pub fn message(&self) -> &str {
&self.message
}
pub fn tabline_config(&self) -> &ChromeLine {
&self.tabline
pub fn top_bar_config(&self) -> &ChromeLine {
&self.top_bar
}
pub fn statusline_config(&self) -> &ChromeLine {
&self.statusline
pub fn bottom_bar_config(&self) -> &ChromeLine {
&self.bottom_bar
}
pub fn label(&self, name: &str) -> &str {
self.labels.get(name).map_or("", String::as_str)
@@ -3881,12 +3881,12 @@ impl Editor {
pub fn set_chrome_config(
&mut self,
mut tabline: ChromeLine,
mut statusline: ChromeLine,
mut top: ChromeLine,
mut bottom: ChromeLine,
labels: BTreeMap<String, String>,
) -> Result<(), String> {
tabline.validate(true)?;
statusline.validate(false)?;
top.validate()?;
bottom.validate()?;
let mut total = 0usize;
for (name, text) in &labels {
validate_label(name, text)?;
@@ -3898,8 +3898,8 @@ impl Editor {
return Err("Labels exceed 4 MiB".into());
}
}
self.tabline = tabline;
self.statusline = statusline;
self.top_bar = top;
self.bottom_bar = bottom;
self.labels = labels
.into_iter()
.map(|(name, text)| (name, sanitize_label(text)))
@@ -17749,9 +17749,11 @@ mod tests {
fn chrome_installation_and_invalid_label_batches_are_atomic() {
let mut editor = editor();
editor.set_label("battery".into(), "ready".into()).unwrap();
let mut top = ChromeLine::tabline();
let mut top = ChromeLine::top();
top.left = vec!["mode".into(), "language".into()];
top.center.push("workspace".into());
let bottom = ChromeLine::statusline();
let mut bottom = ChromeLine::bottom();
bottom.left = vec!["tabs".into()];
editor
.set_chrome_config(
top.clone(),
@@ -17764,18 +17766,34 @@ mod tests {
.unwrap();
assert_eq!(editor.label("workspace"), "tools�");
assert_eq!(
editor.tabline_config().segment("workspace"),
editor.top_bar_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");
let installed_top = editor.top_bar_config().clone();
let installed_bottom = editor.bottom_bar_config().clone();
assert_eq!(installed_top.left, ["mode", "language"]);
assert_eq!(installed_bottom.left, ["tabs"]);
top.right.push("uninstalled".into());
bottom.center.push("uninstalled".into());
let mut invalid_top = top.clone();
invalid_top.segments.get_mut("tabs").unwrap().title = "{missing}".into();
let mut invalid_bottom = bottom.clone();
invalid_bottom.segments.get_mut("mode").unwrap().padding = 65;
for (top, bottom, labels) in [
(invalid_top, bottom.clone(), BTreeMap::new()),
(top.clone(), invalid_bottom, BTreeMap::new()),
(
top,
bottom,
BTreeMap::from([("bad-name".into(), "invalid".into())]),
),
] {
assert!(editor.set_chrome_config(top, bottom, labels).is_err());
assert_eq!(editor.top_bar_config(), &installed_top);
assert_eq!(editor.bottom_bar_config(), &installed_bottom);
assert_eq!(editor.label("battery"), "ready");
assert_eq!(editor.label("workspace"), "tools�");
}
assert!(
editor
.apply(vec![
+82 -60
View File
@@ -38,8 +38,8 @@ pub struct Config {
pub file_picker_view: String,
pub file_explorer_view: String,
pub clipboard_mode: String,
pub tabline: ChromeLine,
pub statusline: ChromeLine,
pub top_bar: ChromeLine,
pub bottom_bar: ChromeLine,
pub labels: BTreeMap<String, String>,
pub diff_context_lines: usize,
pub diff_expand_lines: usize,
@@ -77,8 +77,8 @@ impl Default for Config {
file_picker_view: "tree".into(),
file_explorer_view: "tree".into(),
clipboard_mode: "system".into(),
tabline: ChromeLine::tabline(),
statusline: ChromeLine::statusline(),
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
labels: BTreeMap::new(),
diff_context_lines: 10,
diff_expand_lines: 15,
@@ -102,8 +102,8 @@ pub fn load_config(path: &Path) -> Result<Config, String> {
file_picker_view,
file_explorer_view,
clipboard_mode,
tabline,
statusline,
top_bar,
bottom_bar,
labels,
diff_context_lines,
diff_expand_lines,
@@ -129,8 +129,8 @@ 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 = tabline;
config.statusline = statusline;
config.top_bar = top_bar;
config.bottom_bar = bottom_bar;
config.labels = labels;
config.diff_context_lines = diff_context_lines;
config.diff_expand_lines = diff_expand_lines;
@@ -450,10 +450,10 @@ enum Response {
file_picker_view: String,
file_explorer_view: String,
clipboard_mode: String,
#[serde(deserialize_with = "crate::chrome::tabline")]
tabline: ChromeLine,
#[serde(deserialize_with = "crate::chrome::statusline")]
statusline: ChromeLine,
#[serde(deserialize_with = "crate::chrome::top")]
top_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::bottom")]
bottom_bar: ChromeLine,
#[serde(deserialize_with = "crate::chrome::labels")]
labels: BTreeMap<String, String>,
#[serde(deserialize_with = "diff_context_lines")]
@@ -1181,9 +1181,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","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}"#,
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}"#,
] {
assert!(serde_json::from_str::<Response>(frame).is_err(), "{frame}");
}
@@ -1196,69 +1196,91 @@ end
"config.rb",
r#"
Vamp.configure do |config|
config.tabline = {left: [:tabs], center: ["workspace"], right: [:battery],
config.bar.top = {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.bar.bottom = {"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.top_bar.left, ["tabs"]);
assert_eq!(loaded.top_bar.center, ["workspace"]);
assert_eq!(loaded.top_bar.right, ["battery"]);
assert_eq!(
loaded.tabline.segment("tabs").title,
loaded.top_bar.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.top_bar.segment("tabs").padding, 0);
assert_eq!(loaded.top_bar.segment("tabs").trailing_edge, "▓▒░");
assert_eq!(loaded.top_bar.segment("battery").leading_edge, "界");
assert_eq!(
loaded.tabline.segment("workspace"),
loaded.top_bar.segment("workspace"),
&crate::chrome::SegmentConfig::default()
);
assert!(loaded.statusline.left.is_empty());
assert_eq!(loaded.statusline.right, ["position"]);
assert!(loaded.bottom_bar.left.is_empty());
assert_eq!(loaded.bottom_bar.right, ["position"]);
assert_eq!(loaded.labels["battery"], "bat 38%");
assert_eq!(loaded.labels["workspace"], "界 e\u{301} 👩‍🚀\t\n\u{1b}");
}
#[test]
fn bar_namespace_is_stable_and_accepts_swapped_builtins() {
let fixture = Fixture::new();
let path = fixture.file(
"config.rb",
r#"
Vamp.configure do |config|
bar = config.bar
raise "bar namespace must be stable" unless bar.equal?(config.bar)
bar.top = {left: [:mode, :language], right: [], mode: {padding: 0}}
config.bar.bottom = {left: [:tabs], right: [], tabs: {title: "{name}{modified}"}}
end
"#,
);
let loaded = load_config(&path).unwrap();
assert_eq!(loaded.top_bar.left, ["mode", "language"]);
assert_eq!(loaded.top_bar.segment("mode").padding, 0);
assert_eq!(loaded.top_bar.segment("language").padding, 1);
assert_eq!(loaded.bottom_bar.left, ["tabs"]);
assert_eq!(loaded.bottom_bar.segment("tabs").title, "{name}{modified}");
for setter in ["bar", "tabline", "statusline"] {
let path = fixture.file(
"config.rb",
&format!("Vamp.configure {{ |config| config.{setter} = {{}} }}"),
);
let error = load_config(&path).unwrap_err();
assert!(error.contains("undefined method"), "{setter}: {error}");
}
}
#[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"),
("bar.top", "[]", "must be a Hash"),
("bar.bottom", "[]", "must be a Hash"),
("bar.top", "{left: :tabs}", "must be an Array"),
("bar.top", "{left: [7]}", "must be a String"),
("bar.top", "{left: [:bad_name!]}", "must match"),
(
"tabline",
"{left: [:mode]}",
"only available in the statusline",
),
(
"statusline",
"{right: [:tabs]}",
"only available in the tabline",
),
(
"tabline",
"bar.bottom",
"{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"),
("bar.top", r#"{mode: {leading_edge: "\n"}}"#, "single-line"),
("bar.top", "{language: {padding: 65}}", "between 0 and 64"),
("bar.bottom", "{tabs: {padding: '1'}}", "must be an Integer"),
("bar.top", "{mode: {title: nil}}", "must be a String"),
(
"tabline",
"bar.bottom",
"{tabs: {color: 'red'}}",
"unknown chrome style option",
),
(
"tabline",
"bar.top",
"{left: [], 'left' => []}",
"duplicate chrome key",
),
@@ -1310,7 +1332,7 @@ end
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"tabline": {}, "statusline": {}, "labels": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 0, "diff_expand_lines": 7
});
for setting in ["diff_context_lines", "diff_expand_lines"] {
@@ -1723,7 +1745,7 @@ end"#,
"version": 1, "type": "config", "plugins": [], "lsp": {},
"bindings": [], "leader": "space", "file_explorer_side": "right",
"file_picker_view": "tree", "file_explorer_view": "tree",
"tabline": {}, "statusline": {}, "labels": {},
"top_bar": {}, "bottom_bar": {}, "labels": {},
"clipboard_mode": "system", "diff_context_lines": 10, "diff_expand_lines": 15
});
let Response::Config { lsp, .. } =
@@ -1806,18 +1828,18 @@ end"#,
for mutation in [
"return '['",
"frame.delete('lsp')",
"frame.delete('tabline')",
"frame.delete('statusline')",
"frame.delete('top_bar')",
"frame.delete('bottom_bar')",
"frame.delete('labels')",
"frame['tabline'] = 7",
"frame['statusline'] = []",
"frame['top_bar'] = 7",
"frame['bottom_bar'] = []",
"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['bottom_bar'] = {'tabs' => {'title' => '{unknown}'}}",
"frame['top_bar'] = {'mode' => {'leading_edge' => \"\\n\"}}",
"frame['top_bar'] = {'left' => nil}",
"frame['bottom_bar'] = {'left' => ['bad-name']}",
"frame['bottom_bar'] = {'tabs' => {'padding' => '1'}}",
"frame['top_bar'] = {'language' => {'color' => 'red'}}",
"frame['labels'] = {'bad-name' => ''}",
"frame['leader'] = 7",
"frame['unexpected'] = true",
+69 -56
View File
@@ -12,11 +12,77 @@ module Vamp
name
end
# @param value [Hash{String, Symbol => Object}]
# @return [Hash{String => Object}]
def self.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 = 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 self.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| chrome_name!(item) }]
else
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 = string!(item, option)
else
raise ArgumentError, "unknown chrome style option: #{option}"
end
[option, item]
end
[name, style]
end
end
end
# Collects options for the independently configured top and bottom bars.
class BarConfig
# @return [Hash{String => Object}]
attr_reader :top, :bottom
# @return [void]
def initialize
@top = {}
@bottom = {}
end
# @param options [Hash{String, Symbol => Object}]
# @return [Hash{String => Object}]
def top=(options)
@top = Vamp.chrome_line!(options)
end
# @param options [Hash{String, Symbol => Object}]
# @return [Hash{String => Object}]
def bottom=(options)
@bottom = Vamp.chrome_line!(options)
end
end
# Collects and validates the Ruby startup configuration DSL.
class Config
# @return [Hash{String => Boolean, Hash{String, Symbol => Object}}] language presets/options
attr_reader :lsp
# @return [Vamp::BarConfig]
attr_reader :bar
# @return [void]
def initialize
@plugins = []
@@ -27,8 +93,7 @@ module Vamp
@file_picker_view = "tree"
@file_explorer_view = "tree"
@clipboard_mode = "system"
@tabline = {}
@statusline = {}
@bar = BarConfig.new
@labels = {}
@diff_context_lines = 10
@diff_expand_lines = 15
@@ -76,22 +141,10 @@ module Vamp
@clipboard_mode = nonempty_string!(mode, "clipboard mode")
end
# @param options [Hash{String, Symbol => Object}]
# @return [Hash]
def tabline=(options)
@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|
@labels = Vamp.chrome_hash!(values).to_h do |name, text|
[Vamp.chrome_name!(name), Vamp.string!(text, "label text")]
end
end
@@ -139,51 +192,11 @@ module Vamp
{ "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,
"file_explorer_view" => @file_explorer_view, "clipboard_mode" => @clipboard_mode,
"tabline" => @tabline, "statusline" => @statusline, "labels" => @labels,
"top_bar" => @bar.top, "bottom_bar" => @bar.bottom, "labels" => @labels,
"diff_context_lines" => @diff_context_lines, "diff_expand_lines" => @diff_expand_lines }
end
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 label [String]
# @param minimum [Integer]
+7 -5
View File
@@ -221,7 +221,7 @@ impl EditorSession {
}
editor.set_diff_context_options(config.diff_context_lines, config.diff_expand_lines);
if let Err(error) =
editor.set_chrome_config(config.tabline, config.statusline, config.labels)
editor.set_chrome_config(config.top_bar, config.bottom_bar, config.labels)
{
notices.push(format!("chrome: {error}"));
}
@@ -3016,16 +3016,18 @@ end
let session = fixture.open(
r#"
Vamp.configure do |config|
config.tabline = {right: [:battery]}
config.statusline = {center: [:workspace]}
config.bar.top = {left: [:mode, :language], right: [:battery]}
config.bar.bottom = {left: [:tabs], 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.top_bar_config().left, ["mode", "language"]);
assert_eq!(session.editor.top_bar_config().right, ["battery"]);
assert_eq!(session.editor.bottom_bar_config().left, ["tabs"]);
assert_eq!(session.editor.bottom_bar_config().center, ["workspace"]);
assert_eq!(session.editor.lines(), ["source"]);
assert_eq!(session.editor.text_revision(), 0);
assert!(!session.editor.dirty());
+478 -135
View File
@@ -406,15 +406,9 @@ impl BufferViewports {
self.parked
.retain(|id, _| editor.pane_buffer_id(*id).is_some());
self.syntax.retain(|id, _| editor.has_buffer(*id));
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let syntax = self.syntax.entry(editor.buffer_id()).or_default();
let [full_workspace, prompt] = draw_bars(frame, editor, frame.area(), syntax);
let multiple = editor.pane_layout().len() > 1;
let chrome = if !multiple && editor.diff().is_some() {
None
} else {
Some(document_layout(area, editor))
};
let full_workspace = chrome.map_or(area, |areas| areas[0]);
let diff_header = editor
.diff()
.filter(|diff| !multiple && diff.phase == Phase::View)
@@ -452,6 +446,10 @@ impl BufferViewports {
.visit_rects(pane_rect(workspace), |id, area| {
self.rects.push((id, terminal_rect(area)));
});
// An active prompt still needs its focused render pass when no document row fits.
if self.rects.is_empty() && prompt.height > 0 {
self.rects.push((focused, workspace));
}
// Paint focused overlays last, so an adjacent leaf cannot erase them.
for active_pass in [false, true] {
for index in 0..self.rects.len() {
@@ -474,12 +472,11 @@ impl BufferViewports {
frame.render_widget(block, leaf);
leaf = inner;
}
let status = chrome
.filter(|_| id == focused)
.map_or(Rect::default(), |a| a[1]);
let prompt = chrome
.filter(|_| id == focused)
.map_or(Rect::default(), |a| a[2]);
let prompt = if id == focused {
prompt
} else {
Rect::default()
};
editor.with_render_pane(id, |view| {
self.activate_document(view.buffer_id());
self.documents.retain(|id, _| view.has_buffer(*id));
@@ -492,8 +489,8 @@ impl BufferViewports {
draw_document(
frame,
view,
[leaf, status, prompt],
area,
[leaf, prompt],
full_workspace,
top,
left,
syntax,
@@ -544,14 +541,20 @@ impl BufferViewports {
draw_diff(
frame,
diff,
area,
full_workspace,
editor.selection_navigation_hint(),
editor.file_navigation_hint(),
&mut self.panels,
Some(editor),
);
} else {
draw_overlays(frame, editor, area, self.panels.caret, &mut self.panels);
draw_overlays(
frame,
editor,
full_workspace,
self.panels.caret,
&mut self.panels,
);
}
self.diff_visible = self
.panels
@@ -1071,7 +1074,7 @@ fn chrome_items<'a>(
editor: &'a Editor,
line: &'a ChromeLine,
names: &[String],
syntax: Option<&SyntaxCache>,
syntax: &SyntaxCache,
) -> Vec<ChromeItem<'a>> {
let mut items = Vec::new();
for name in names {
@@ -1100,31 +1103,28 @@ fn chrome_items<'a>(
continue;
}
let mut text = match name.as_str() {
"mode" if syntax.is_some() => Cow::Owned(if editor.visual_linewise() {
"mode" => Cow::Owned(if editor.visual_linewise() {
"VISUAL LINE".into()
} else {
editor.mode().to_string()
}),
"path" if syntax.is_some() => Cow::Owned(chrome_title(
"path" => Cow::Owned(chrome_title(
"{path}{modified}",
editor.tab_index() + 1,
editor.path(),
editor.dirty(),
)),
"position" if syntax.is_some() => Cow::Owned(format!(
"position" => Cow::Owned(format!(
"{}:{}",
editor.cursor().row + 1,
editor.cursor().column + 1
)),
"language" if syntax.is_some() => {
let syntax = syntax.unwrap();
Cow::Owned(if let Some(error) = syntax.error() {
format!("syntax: {error}")
} else {
syntax.language().unwrap_or_default().to_owned()
})
}
"diagnostics" if syntax.is_some() => {
"language" => Cow::Owned(if let Some(error) = syntax.error() {
format!("syntax: {error}")
} else {
syntax.language().unwrap_or_default().to_owned()
}),
"diagnostics" => {
let mut counts = [0; 4];
for diagnostic in editor.diagnostics() {
counts[diagnostic_severity(diagnostic.severity).0] += 1;
@@ -1140,18 +1140,18 @@ fn chrome_items<'a>(
}
Cow::Owned(text)
}
"selections" if syntax.is_some() => Cow::Owned(if editor.selection_count() > 1 {
"selections" => Cow::Owned(if editor.selection_count() > 1 {
format!("{} selections", editor.selection_count())
} else {
String::new()
}),
"count" if syntax.is_some() => Cow::Owned(
"count" => Cow::Owned(
editor
.count()
.map(|count| format!("count: {count}"))
.unwrap_or_default(),
),
"pending" if syntax.is_some() => {
"pending" => {
let mut text = editor.sequence_pending().unwrap_or_default().to_owned();
for pending in [
editor.g_pending().then_some('g'),
@@ -1168,7 +1168,7 @@ fn chrome_items<'a>(
}
Cow::Owned(text)
}
"buffer" if syntax.is_some() => Cow::Owned(if editor.buffer_count() > 1 {
"buffer" => Cow::Owned(if editor.buffer_count() > 1 {
format!("[buffer {}/{}]", editor.buffer_id(), editor.buffer_count())
} else {
String::new()
@@ -1348,47 +1348,69 @@ fn draw_chrome(frame: &mut Frame, mut groups: [Vec<ChromeItem<'_>>; 3], area: Re
}
}
fn draw_tab_bar(frame: &mut Frame, editor: &Editor, area: Rect) -> Rect {
if area.height == 0 {
return area;
}
let line = editor.tabline_config();
let anchors = [&line.left, &line.center, &line.right];
if !anchors.iter().flat_map(|names| names.iter()).any(|name| {
if name == "tabs" {
editor.tab_count() > 1
} else {
!editor.label(name).is_empty()
}
}) {
return area;
}
let groups = anchors.map(|names| chrome_items(editor, line, names, None));
if !groups.iter().flatten().any(|item| item.tab.is_none())
&& !(editor.tab_count() > 1 && groups.iter().any(|group| !group.is_empty()))
{
return area;
}
draw_chrome(frame, groups, Rect::new(area.x, area.y, area.width, 1));
Rect::new(
area.x,
area.y.saturating_add(1),
area.width,
area.height - 1,
)
fn chrome_groups<'a>(
editor: &'a Editor,
line: &'a ChromeLine,
syntax: &SyntaxCache,
) -> [Vec<ChromeItem<'a>>; 3] {
[&line.left, &line.center, &line.right].map(|names| chrome_items(editor, line, names, syntax))
}
fn document_layout(area: Rect, editor: &Editor) -> [Rect; 3] {
fn chrome_visible(editor: &Editor, groups: &[Vec<ChromeItem<'_>>; 3]) -> bool {
groups.iter().flatten().any(|item| item.tab.is_none())
|| (editor.tab_count() > 1 && groups.iter().any(|group| !group.is_empty()))
}
fn document_layout(area: Rect, editor: &Editor, visible: [bool; 2]) -> [Rect; 4] {
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
let dock_focused = editor
.file_picker()
.is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked);
Layout::vertical([
Constraint::Fill(1),
Constraint::Length(u16::from(area.height >= 3)),
Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))),
])
.areas(area)
let prompt =
u16::from(editor.diff().is_none() && (area.height >= 2 || (prompting && !dock_focused)))
.min(area.height);
// Both bars yield to the separate prompt and the last document row.
let available = area.height.saturating_sub(prompt + 1);
let top = u16::from(visible[0]).min(available);
let bottom = u16::from(visible[1]).min(available - top);
let workspace = Rect::new(
area.x,
area.y.saturating_add(top),
area.width,
area.height - top - bottom - prompt,
);
[
Rect::new(area.x, area.y, area.width, top),
workspace,
Rect::new(area.x, workspace.bottom(), area.width, bottom),
Rect::new(area.x, area.bottom() - prompt, area.width, prompt),
]
}
fn draw_bars(
frame: &mut Frame,
editor: &Editor,
area: Rect,
syntax: &mut SyntaxCache,
) -> [Rect; 2] {
if area.width == 0 || area.height == 0 {
let [_, workspace, _, prompt] = document_layout(area, editor, [false; 2]);
return [workspace, prompt];
}
// Resolve the active buffer's language before computing either bar's visibility.
syntax.update_visible(editor.lines(), editor.path(), editor.text_revision(), &[]);
let groups = [
chrome_groups(editor, editor.top_bar_config(), syntax),
chrome_groups(editor, editor.bottom_bar_config(), syntax),
];
let visible = groups
.each_ref()
.map(|groups| chrome_visible(editor, groups));
let [top, workspace, bottom, prompt] = document_layout(area, editor, visible);
for (groups, area) in groups.into_iter().zip([top, bottom]) {
draw_chrome(frame, groups, area);
}
[workspace, prompt]
}
#[cfg(test)]
@@ -1402,14 +1424,9 @@ fn draw(
panels: &mut WheelPanels,
) {
let area = frame.area();
let area = draw_tab_bar(frame, editor, area);
let areas = if editor.diff().is_some() {
[area, Rect::default(), Rect::default()]
} else {
document_layout(area, editor)
};
let areas = draw_bars(frame, editor, area, syntax);
draw_document(
frame, editor, areas, area, top, left, syntax, document, panels,
frame, editor, areas, areas[0], top, left, syntax, document, panels,
);
}
@@ -1417,7 +1434,7 @@ fn draw(
fn draw_document(
frame: &mut Frame,
editor: &Editor,
[workspace, status_area, prompt_area]: [Rect; 3],
[workspace, prompt_area]: [Rect; 2],
overlay_area: Rect,
top: &mut usize,
left: &mut usize,
@@ -1430,7 +1447,6 @@ fn draw_document(
let area = workspace;
let focused = editor.render_pane_focused();
if let Some(diff) = editor.diff() {
draw_status(frame, editor, syntax, status_area);
if panels.workspace_managed && diff.phase != Phase::View {
return;
}
@@ -1446,7 +1462,7 @@ fn draw_document(
return;
}
panels.diff_syntax = None;
if overlay_area.width == 0 || overlay_area.height == 0 {
if overlay_area.width == 0 || (overlay_area.height == 0 && prompt_area.height == 0) {
return;
}
let prompting = matches!(editor.mode(), Mode::Command | Mode::Search);
@@ -1759,7 +1775,6 @@ fn draw_document(
);
}
}
draw_status(frame, editor, syntax, status_area);
if prompting && prompt_area.height > 0 && prompt_area.width > 0 {
let prefix = if editor.mode() == Mode::Search {
'/'
@@ -1895,13 +1910,6 @@ fn draw_overlays(
}
}
fn draw_status(frame: &mut Frame, editor: &Editor, syntax: &SyntaxCache, status_area: Rect) {
let line = editor.statusline_config();
let groups = [&line.left, &line.center, &line.right]
.map(|names| chrome_items(editor, line, names, Some(syntax)));
draw_chrome(frame, groups, status_area);
}
struct DiffGeometry {
regions: [(Rect, Focus); 3],
viewport: Option<(u64, usize, usize, usize, usize)>,
@@ -5447,8 +5455,8 @@ mod tests {
editor: &mut Editor,
update: impl FnOnce(&mut ChromeLine, &mut ChromeLine),
) {
let mut top = editor.tabline_config().clone();
let mut bottom = editor.statusline_config().clone();
let mut top = editor.top_bar_config().clone();
let mut bottom = editor.bottom_bar_config().clone();
update(&mut top, &mut bottom);
editor
.set_chrome_config(top, bottom, BTreeMap::new())
@@ -5490,7 +5498,11 @@ mod tests {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(160, 3)).unwrap();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
})
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
@@ -5508,7 +5520,11 @@ mod tests {
});
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
})
.unwrap();
let titles = pane_screen(&terminal, Rect::new(0, 0, 160, 1));
@@ -5554,8 +5570,16 @@ mod tests {
let syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_status(frame, &editor, &syntax, Rect::new(3, 2, 45, 1));
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
draw_chrome(
frame,
chrome_groups(&editor, editor.bottom_bar_config(), &syntax),
Rect::new(3, 2, 45, 1),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
@@ -5570,8 +5594,16 @@ mod tests {
editor.set_label("left".into(), "L".repeat(30)).unwrap();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_status(frame, &editor, &syntax, Rect::new(3, 2, 45, 1));
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
draw_chrome(
frame,
chrome_groups(&editor, editor.bottom_bar_config(), &syntax),
Rect::new(3, 2, 45, 1),
);
})
.unwrap();
for (y, x, width) in [(0, 0, 51), (2, 3, 45)] {
@@ -5585,7 +5617,7 @@ mod tests {
fn active_tabs_keep_their_anchor_and_text_when_optional_groups_collide() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.execute("tab.new");
configure_chrome(&mut editor, |top, _| {
configure_chrome(&mut editor, |top, bottom| {
top.left = vec!["prefix".into()];
top.center = vec!["tabs".into()];
top.right = vec!["suffix".into()];
@@ -5594,6 +5626,7 @@ mod tests {
tabs.padding = 64;
tabs.leading_edge = "界".repeat(8);
tabs.trailing_edge = "界".repeat(8);
*bottom = top.clone();
});
editor
.set_label("prefix".into(), "LEFT".repeat(32))
@@ -5605,29 +5638,42 @@ mod tests {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()]
.into_iter()
.enumerate()
{
draw_chrome(
frame,
chrome_groups(&editor, line, &SyntaxCache::new()),
Rect::new(0, y as u16, width, 1),
);
}
})
.unwrap();
if width > 0 {
let row = pane_screen(&terminal, Rect::new(0, 0, width, 1));
for y in 0..2 {
if width == 0 {
continue;
}
let row = pane_screen(&terminal, Rect::new(0, y, width, 1));
assert!(
row.trim()
.starts_with(if width == 1 { "2" } else { "2:active" }),
"{row}"
);
assert_eq!(
terminal.backend().buffer()[((width - width.min(8)) / 2, 0)].symbol(),
terminal.backend().buffer()[((width - width.min(8)) / 2, y)].symbol(),
"2"
);
assert!(!row.contains("LEFT") && !row.contains("RIGHT"), "{row}");
assert!(!row.contains('界'), "{row}");
}
}
configure_chrome(&mut editor, |top, _| {
configure_chrome(&mut editor, |top, bottom| {
let tabs = top.segments.get_mut("tabs").unwrap();
tabs.padding = 0;
tabs.leading_edge.clear();
tabs.trailing_edge.clear();
*bottom = top.clone();
});
editor
.set_label("prefix".into(), "LEFT".repeat(32))
@@ -5638,11 +5684,22 @@ mod tests {
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 2)).unwrap();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
for (y, line) in [editor.top_bar_config(), editor.bottom_bar_config()]
.into_iter()
.enumerate()
{
draw_chrome(
frame,
chrome_groups(&editor, line, &SyntaxCache::new()),
Rect::new(0, y as u16, 40, 1),
);
}
})
.unwrap();
let row = pane_screen(&terminal, Rect::new(0, 0, 40, 1));
assert_eq!(&row[16..24], "2:active");
for y in 0..2 {
let row = pane_screen(&terminal, Rect::new(0, y, 40, 1));
assert_eq!(&row[16..24], "2:active");
}
}
#[test]
@@ -5683,8 +5740,16 @@ mod tests {
let syntax = SyntaxCache::new();
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_status(frame, &editor, &syntax, Rect::new(0, 2, 40, 1));
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
draw_chrome(
frame,
chrome_groups(&editor, editor.bottom_bar_config(), &syntax),
Rect::new(0, 2, 40, 1),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
@@ -5726,7 +5791,11 @@ mod tests {
editor.execute("tab.previous");
terminal
.draw(|frame| {
draw_tab_bar(frame, &editor, frame.area());
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
frame.area(),
);
})
.unwrap();
let buffer = terminal.backend().buffer();
@@ -5738,7 +5807,7 @@ mod tests {
}
#[test]
fn empty_configured_chrome_does_not_allocate_a_tab_row() {
fn empty_bar_content_reclaims_both_rows_without_hiding_the_prompt() {
let mut editor = Editor::open(None, vec![]).unwrap();
editor.execute("tab.new");
configure_chrome(&mut editor, |top, bottom| {
@@ -5755,28 +5824,292 @@ mod tests {
first_leading_edge: None,
},
);
bottom.left.clear();
bottom.center.clear();
bottom.right.clear();
*bottom = top.clone();
});
let mut syntax = SyntaxCache::new();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
assert_eq!(draw_tab_bar(frame, &editor, area), area);
assert_eq!(document_layout(area, &editor)[1].height, 1);
let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax);
assert_eq!(workspace, Rect::new(0, 0, 40, 4));
assert_eq!(prompt, Rect::new(0, 4, 40, 1));
})
.unwrap();
editor.set_label("empty".into(), "present".into()).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
assert_eq!(draw_tab_bar(frame, &editor, area).y, 1);
let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax);
assert_eq!(workspace, Rect::new(0, 1, 40, 2));
assert_eq!(prompt, Rect::new(0, 4, 40, 1));
})
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, 40, 1));
assert!(title.contains("Not visible without a value"), "{title}");
assert!(!title.contains('▓'), "{title}");
for y in [0, 3] {
let title = pane_screen(&terminal, Rect::new(0, y, 40, 1));
assert!(title.contains("Not visible without a value"), "{title}");
assert!(!title.contains('▓'), "{title}");
}
editor.handle(Input::Char(':'));
terminal
.draw(|frame| {
let [workspace, prompt] = draw_bars(frame, &editor, frame.area(), &mut syntax);
assert_eq!(workspace.height, 2);
assert_eq!(prompt, Rect::new(0, 4, 40, 1));
})
.unwrap();
}
#[test]
fn tiny_bars_yield_to_document_and_prompt_identically_in_either_position() {
let directory = Directory::new();
let path = directory.file("source.txt", "DOCUMENT");
for segment in ["tabs", "mode"] {
for at_top in [false, true] {
for prompting in [false, true] {
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.execute("tab.new");
configure_chrome(&mut editor, |top, bottom| {
for line in [&mut *top, &mut *bottom] {
line.left.clear();
line.center.clear();
line.right.clear();
}
let line = if at_top { top } else { bottom };
line.left = vec![segment.into()];
});
if prompting {
editor.handle(Input::Char(':'));
}
for height in 1..=4 {
let area = Rect::new(3, 2, 80, height);
let mut terminal = Terminal::with_options(
ratatui::backend::TestBackend::new(100, 10),
ratatui::TerminalOptions {
viewport: ratatui::Viewport::Fixed(area),
},
)
.unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let bar_text = match segment {
"tabs" => "2 source.txt",
_ if prompting => "COMMAND",
_ => "NORMAL",
};
let visible = height >= 3;
assert_eq!(screen_text(&terminal).contains(bar_text), visible);
let cursor = terminal.get_cursor_position().unwrap();
assert!(
area.contains(cursor),
"{segment} {at_top} prompting={prompting} {area:?}: {cursor:?}"
);
if prompting {
assert_eq!(cursor.y, area.bottom() - 1);
assert_eq!(
terminal.backend().buffer()[(area.x, cursor.y)].symbol(),
":",
);
}
if !prompting || height > 1 {
let viewport = views.document.geometry.unwrap().1;
assert!(viewport.height > 0);
assert!(pane_screen(&terminal, viewport).contains("DOCUMENT"));
}
if visible {
let y = if at_top { area.y } else { area.bottom() - 2 };
assert!(
pane_screen(&terminal, Rect::new(area.x, y, area.width, 1))
.contains(bar_text)
);
}
}
}
}
}
}
#[test]
fn tabs_only_singletons_hide_in_either_bar_and_reappear_with_another_tab() {
let mut editor = Editor::open(None, vec![]).unwrap();
configure_chrome(&mut editor, |top, bottom| *bottom = top.clone());
let mut views = BufferViewports::default();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 11));
assert!(views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, 0),));
editor.execute("tab.new");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects[0].1, Rect::new(0, 1, 80, 9));
for y in [0, 10] {
assert!(
!views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)
);
}
let caret = terminal.get_cursor_position().unwrap();
assert!(caret.y >= 1 && caret.y < 10);
for y in [0, 10] {
let bar = pane_screen(&terminal, Rect::new(0, y, 80, 1));
assert!(
bar.contains("1 [No Name]") && bar.contains("2 [No Name]"),
"{bar}"
);
}
editor.execute("tab.close");
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects[0].1, Rect::new(0, 0, 80, 11));
assert!(!screen_text(&terminal).contains("[No Name]"));
for y in [0, 10] {
assert!(
views.scroll_document(&editor, document_wheel(MouseEventKind::ScrollDown, 2, y),)
);
}
}
#[test]
fn swapped_bars_render_active_metadata_and_language_on_the_first_paint() {
let directory = Directory::new();
let path = directory.file("active.rs", "fn main() {}");
let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap();
editor.execute("tab.new");
editor.handle(Input::Char('i'));
editor.handle(Input::Paste("// changed\n".into()));
editor.handle(Input::Escape);
configure_chrome(&mut editor, std::mem::swap);
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(240, 12)).unwrap();
let mut views = BufferViewports::default();
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let top = pane_screen(&terminal, Rect::new(0, 0, 240, 1));
assert!(top.contains("NORMAL"), "{top}");
assert!(top.contains(&path.display().to_string()), "{top}");
assert!(top.contains("[+]") && top.contains("Rust"), "{top}");
let cursor = editor.cursor();
assert!(
top.contains(&format!("{}:{}", cursor.row + 1, cursor.column + 1)),
"{top}"
);
let bottom = pane_screen(&terminal, Rect::new(0, 10, 240, 1));
assert!(
bottom.contains("1 active.rs [+]") && bottom.contains("2 active.rs [+]"),
"{bottom}"
);
assert_eq!(views.document.geometry.unwrap().1.y, 1);
let before = views.rects[0].1;
editor.handle(Input::Char(':'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(views.rects[0].1, before);
assert!(pane_screen(&terminal, Rect::new(0, 11, 240, 1)).starts_with(':'));
assert_eq!(terminal.get_cursor_position().unwrap().y, 11);
assert_eq!(pane_screen(&terminal, Rect::new(0, 10, 240, 1)), bottom);
}
#[test]
fn both_bars_bound_palettes_diff_selectors_docks_and_diff_prompts() {
let directory = Directory::new();
let mut editor =
Editor::open(Some(directory.file("source.rs", "fn main() {}")), vec![]).unwrap();
editor.execute("tab.new");
configure_chrome(&mut editor, |top, bottom| {
std::mem::swap(top, bottom);
top.left = vec!["mode".into(), "language".into()];
top.center = vec!["workspace".into()];
top.right = vec!["position".into()];
bottom.right = vec!["battery".into()];
});
editor
.set_label("workspace".into(), "WORKSPACE".into())
.unwrap();
editor.set_label("battery".into(), "BOTTOM".into()).unwrap();
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
let mut views = BufferViewports::default();
let assert_bars = |terminal: &Terminal<ratatui::backend::TestBackend>,
views: &BufferViewports| {
assert!(pane_screen(terminal, Rect::new(0, 0, 120, 1)).contains("WORKSPACE"));
let bottom_y = if views.panels.diff.is_some() { 29 } else { 28 };
assert!(pane_screen(terminal, Rect::new(0, bottom_y, 120, 1)).contains("BOTTOM"));
for (_, rect) in &views.rects {
assert!(rect.y >= 1 && rect.bottom() <= bottom_y);
}
if let Some(geometry) = &views.panels.diff {
for (rect, _) in geometry.regions {
if rect.height > 0 {
assert!(rect.y >= 1 && rect.bottom() <= bottom_y);
}
}
}
if let Some((rect, _, _)) = views.panels.files {
assert!(rect.y >= 1 && rect.bottom() <= bottom_y);
}
};
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)).contains("Rust"));
assert_bars(&terminal, &views);
editor.handle(Input::Ctrl('p'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Commands"));
assert_bars(&terminal, &views);
editor.handle(Input::Escape);
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog())));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(screen_text(&terminal).contains("Choose Before"));
assert!(views.document.geometry.is_none());
assert_bars(&terminal, &views);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.apply_diff_result(
id,
Ok(crate::diff::Outcome::Comparison(crate::diff::Comparison {
root: directory.0.clone(),
editable_paths: Default::default(),
before: "baseline".into(),
after: "Working checkout".into(),
files: vec![deletion_file()],
})),
);
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert!(views.panels.files.is_some());
assert_bars(&terminal, &views);
editor.handle(Input::Char(':'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
let cursor = terminal.get_cursor_position().unwrap();
assert!(Rect::new(0, 1, 120, 28).contains(cursor), "{cursor:?}");
assert_bars(&terminal, &views);
editor.handle(Input::Escape);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
editor.file_picker().unwrap().presentation,
FilePresentation::Floating
);
assert_bars(&terminal, &views);
}
#[test]
@@ -5802,11 +6135,16 @@ mod tests {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
if width > 0 && height > 0 {
if width > 0 && height >= 3 {
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
assert!(title.trim_start().starts_with('3'), "{title}");
assert!(!title.contains("status"), "{title}");
}
if width > 0 && height == 1 {
let (_, viewport) = views.document.geometry.unwrap();
assert!(viewport.contains(terminal.get_cursor_position().unwrap()));
assert!(pane_screen(&terminal, viewport).contains("ACTIVE"));
}
}
let mut editor = Editor::open(None, vec![]).unwrap();
@@ -5890,7 +6228,11 @@ mod tests {
terminal
.draw(|frame| {
let area = frame.area();
assert_eq!(draw_tab_bar(frame, &editor, area).y, 1);
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
area,
);
})
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
@@ -5908,7 +6250,11 @@ mod tests {
terminal
.draw(|frame| {
let area = frame.area();
draw_tab_bar(frame, &editor, area);
draw_chrome(
frame,
chrome_groups(&editor, editor.top_bar_config(), &SyntaxCache::new()),
area,
);
})
.unwrap();
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
@@ -5979,10 +6325,6 @@ mod tests {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
views.panels.files.unwrap().0,
floating_panel(Rect::new(0, 1, 120, 29))
);
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
}
@@ -6185,10 +6527,10 @@ mod tests {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(
views.panels.files.unwrap().0,
floating_panel(Rect::new(0, 0, 120, 30))
);
let panel = views.panels.files.unwrap().0;
assert!(panel.x < 60 && panel.right() > 60);
assert!(panel.y >= views.rects[0].1.y);
assert!(panel.bottom() <= views.rects[0].1.bottom());
}
#[test]
@@ -6764,7 +7106,6 @@ mod tests {
editor.handle(Input::Char(ch));
}
render_native_diff(&mut terminal, &mut views, &mut editor);
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
editor.handle(Input::Enter);
let searched = render_native_diff(&mut terminal, &mut views, &mut editor);
assert!(before.contains(searched));
@@ -7071,7 +7412,6 @@ mod tests {
terminal
.draw(|frame| views.draw(frame, &mut editor))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap().y, 13);
assert!(screen_text(&terminal).contains(":diff.close"));
editor.handle(Input::Enter);
assert!(editor.diff().is_none());
@@ -9989,6 +10329,7 @@ end
let before = (top, left);
editor.handle(Input::Char(' '));
editor.handle(Input::Char('o'));
let mut panels = WheelPanels::default();
terminal
.draw(|frame| {
draw(
@@ -9998,11 +10339,13 @@ end
&mut left,
&mut syntax,
&mut DocumentViewport::default(),
&mut WheelPanels::default(),
&mut panels,
)
})
.unwrap();
let panel = Rect::new(area.x + 5, area.y + 1, 100, 24);
let panel = panels.files.unwrap().0;
assert_eq!(panel.x - area.x, area.right() - panel.right());
assert!(panel.y >= area.y && panel.bottom() <= area.bottom() - 2);
assert_eq!((top, left), before);
assert_eq!(
terminal.get_cursor_position().unwrap(),