feat: redesign status chrome and floating prompts

This commit is contained in:
Valdemar Lorenzen
2026-10-12 12:21:31 +02:00
parent 8c6a050171
commit 52915ffacf
12 changed files with 1333 additions and 373 deletions
+26 -3
View File
@@ -10,6 +10,7 @@ const DEFAULT_BOTTOM_SEGMENTS: &[&str] = &[
"selections",
"count",
"language",
"lsp",
"position",
];
@@ -57,12 +58,12 @@ impl ChromeLine {
pub fn bottom() -> Self {
Self {
left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 1]
left: DEFAULT_BOTTOM_SEGMENTS[..DEFAULT_BOTTOM_SEGMENTS.len() - 3]
.iter()
.map(|name| (*name).into())
.collect(),
center: Vec::new(),
right: vec!["position".into()],
right: vec!["language".into(), "lsp".into(), "position".into()],
segments: DEFAULT_BOTTOM_SEGMENTS
.iter()
.map(|name| ((*name).into(), default_segment(name)))
@@ -267,6 +268,27 @@ mod tests {
bottom: ChromeLine,
}
#[test]
fn bottom_keeps_metadata_on_the_right_without_filled_styles() {
let line = ChromeLine::bottom();
assert_eq!(
line.left,
[
"mode",
"pending",
"buffer",
"diagnostics",
"path",
"selections",
"count"
]
);
assert_eq!(line.right, ["language", "lsp", "position"]);
for name in ["language", "lsp"] {
assert_eq!(line.segment(name), &SegmentConfig::default());
}
}
#[test]
fn omitted_options_inherit_and_explicit_groups_replace_defaults() {
let lines: Lines = serde_json::from_value(serde_json::json!({
@@ -277,7 +299,7 @@ mod tests {
assert_eq!(lines.top.segment("tabs").leading_edge, "░▒▓");
assert_eq!(lines.top.segment("battery").padding, 0);
assert!(lines.bottom.left.is_empty());
assert_eq!(lines.bottom.right, ["position"]);
assert_eq!(lines.bottom.right, ["language", "lsp", "position"]);
assert_eq!(lines.bottom.segment("workspace"), &SegmentConfig::default());
}
@@ -293,6 +315,7 @@ mod tests {
"selections",
"count",
"language",
"lsp",
"position",
"battery",
"workspace",
+289 -17
View File
@@ -1351,11 +1351,14 @@ pub struct Editor {
owned_system_clipboard: Option<(String, Vec<String>, bool)>,
last_edit: Option<EditRecipe>,
message: String,
notification_visible: bool,
top_bar: ChromeLine,
bottom_bar: ChromeLine,
labels: BTreeMap<String, String>,
battery_charge: Option<u8>,
battery_label: String,
lsp_names: Vec<String>,
lsp_label: String,
quit: bool,
capabilities: Vec<String>,
features: HashMap<String, Feature>,
@@ -1441,11 +1444,14 @@ impl Editor {
owned_system_clipboard: None,
last_edit: None,
message: String::new(),
notification_visible: true,
top_bar: ChromeLine::top(),
bottom_bar: ChromeLine::bottom(),
labels: BTreeMap::new(),
battery_charge: None,
battery_label: String::new(),
lsp_names: Vec::new(),
lsp_label: String::new(),
quit: false,
capabilities,
features: HashMap::new(),
@@ -1773,6 +1779,7 @@ impl Editor {
self.line_pending.take(),
self.seek_pending.take(),
std::mem::take(&mut self.message),
std::mem::replace(&mut self.notification_visible, true),
)
});
self.select_tab(target_tab);
@@ -1794,6 +1801,7 @@ impl Editor {
self.line_pending,
self.seek_pending,
self.message,
self.notification_visible,
) = interaction;
}
match result {
@@ -3199,7 +3207,6 @@ impl Editor {
popup: Completion { items, selected: 1 },
original,
});
self.message.clear();
Ok(())
}
@@ -3835,6 +3842,9 @@ impl Editor {
pub fn message(&self) -> &str {
&self.message
}
pub fn notification_visible(&self) -> bool {
self.notification_visible
}
pub fn top_bar_config(&self) -> &ChromeLine {
&self.top_bar
}
@@ -3847,6 +3857,12 @@ impl Editor {
pub fn battery_label(&self) -> &str {
&self.battery_label
}
pub fn lsp_names(&self) -> &[String] {
&self.lsp_names
}
pub fn lsp_label(&self) -> &str {
&self.lsp_label
}
pub fn path(&self) -> Option<&Path> {
self.active.path.as_deref()
}
@@ -3884,6 +3900,7 @@ impl Editor {
} else {
message
};
self.notification_visible = true;
}
pub fn set_chrome_config(
@@ -3928,6 +3945,20 @@ impl Editor {
Ok(true)
}
pub(crate) fn set_lsp_names(&mut self, mut names: Vec<String>) -> bool {
for name in &mut names {
if name.chars().any(char::is_control) {
*name = sanitize_label(std::mem::take(name));
}
}
if self.lsp_names == names {
return false;
}
self.lsp_label = names.join(" + ");
self.lsp_names = names;
true
}
pub(crate) fn set_battery_charge(&mut self, charge: Option<u8>) -> bool {
let charge = charge.filter(|charge| *charge <= 100);
if self.battery_charge == charge {
@@ -4775,6 +4806,14 @@ impl Editor {
if input == Input::Escape && self.count.take().is_some() {
return None;
}
if input == Input::Escape
&& self.active.view.mode == Mode::Normal
&& self.notification_visible
&& (!self.message.is_empty() || self.diagnostic_at_cursor().is_some())
{
self.notification_visible = false;
return None;
}
if matches!(self.active.view.mode, Mode::Normal | Mode::Visual)
&& let Input::Char(ch @ '0'..='9') = input
{
@@ -5124,7 +5163,6 @@ impl Editor {
self.active.view.linewise = linewise;
self.active.view.mode = Mode::Visual;
self.normalize_targets();
self.message.clear();
}
}
Action::SelectLines => self.split_rows(false, false),
@@ -5255,7 +5293,6 @@ impl Editor {
}
self.active.view.visual_motions = None;
self.normalize_targets();
self.message.clear();
self.record_jump(source);
}
@@ -5360,21 +5397,12 @@ impl Editor {
self.parked.insert(previous.id, previous);
self.load_view();
self.reveal_caret();
self.show_buffer();
}
fn show_buffer(&mut self) {
let path = self
.path()
.map_or_else(|| Cow::Borrowed("[No Name]"), Path::to_string_lossy);
self.set_message(format!("Buffer {}: {path}", self.active.id));
}
fn switch_buffer(&mut self, id: usize) {
if id == self.active.id {
self.reveal_caret();
self.park_source();
self.show_buffer();
} else if let Some(buffer) = self.parked.remove(&id) {
self.activate(buffer);
} else {
@@ -6424,7 +6452,6 @@ impl Editor {
}
self.active.view.visual_motions = None;
self.normalize_targets();
self.message.clear();
}
fn line_selections(&mut self, count: usize) -> Vec<Selection> {
@@ -6489,7 +6516,6 @@ impl Editor {
},
);
}
self.message.clear();
self.reveal_caret();
}
@@ -6518,7 +6544,6 @@ impl Editor {
if changing {
self.start_insert(before.unwrap(), recipe.unwrap().start);
self.edit_ranges(action, &ranges);
self.message.clear();
return;
}
self.edit_ranges(action, &ranges);
@@ -6770,7 +6795,6 @@ impl Editor {
});
}
self.record(before);
self.message.clear();
}
if !system {
self.clipboard = fragments;
@@ -7079,7 +7103,6 @@ impl Editor {
}
self.active.view.visual_motions = None;
self.record(before);
self.message.clear();
}
Err(error) => {
if !restored {
@@ -8612,6 +8635,206 @@ mod tests {
}
}
#[test]
fn notification_escape_preserves_carets_then_ordinary_escape_collapses_them() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("abcd\nabcd".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
assert!(editor.notification_visible());
editor.set_message("Written");
editor.handle(Input::Right);
let targets = editor.active.view.targets.clone();
let revision = editor.text_revision();
let history = (editor.active.undo.len(), editor.active.redo.len());
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.message(), "Written");
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.active.view.primary, 1);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
(editor.active.undo.len(), editor.active.redo.len()),
history
);
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
editor.set_message("Written");
assert!(editor.notification_visible());
type_text(&mut editor, "iX");
editor.handle(Input::Escape);
assert!(editor.notification_visible());
assert_eq!(editor.message(), "Written");
editor.handle(Input::Char('v'));
editor.handle(Input::Escape);
assert!(editor.notification_visible());
for prompt in [':', '/'] {
editor.handle(Input::Char(prompt));
editor.handle(Input::Char('x'));
editor.handle(Input::Escape);
assert_eq!(editor.mode(), Mode::Normal);
assert!(editor.notification_visible());
}
}
#[test]
fn notification_escape_waits_for_prefixes_and_focused_overlays() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("abcd\nabcd".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
editor.set_message("Written");
let targets = editor.active.view.targets.clone();
for prefix in ["f", "3g", "2d", "4", " b"] {
type_text(&mut editor, prefix);
editor.handle(Input::Escape);
assert!(editor.notification_visible(), "{prefix}");
assert_eq!(editor.active.view.targets, targets, "{prefix}");
assert_eq!(editor.count(), None);
}
editor.show_startup_diagnostic("startup".into());
editor.handle(Input::Escape);
assert!(editor.startup_diagnostic().is_none());
assert!(editor.notification_visible());
editor.show_hover("hover".into()).unwrap();
editor.handle(Input::Escape);
assert!(editor.hover().is_none());
assert!(editor.notification_visible());
editor.handle(Input::Ctrl('p'));
editor.handle(Input::Escape);
assert!(editor.palette().is_none());
assert!(editor.notification_visible());
editor.open_file_picker(FilePresentation::Docked);
editor.handle(Input::Char('i'));
assert!(editor.file_picker().unwrap().searching);
editor.handle(Input::Escape);
assert!(editor.file_picker().unwrap().focused);
assert!(editor.notification_visible());
editor.handle(Input::Escape);
assert!(!editor.file_picker().unwrap().focused);
assert!(editor.notification_visible());
assert_eq!(editor.active.view.targets, targets);
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.active.view.targets, targets);
}
#[test]
fn empty_notification_does_not_swallow_escape_but_cursor_diagnostic_does() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("abcd\nabcd".into())])
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
assert!(editor.notification_visible());
editor
.replace_diagnostics(
"fixture",
editor.buffer_id(),
editor.text_revision(),
vec![annotation((1, 0), (1, 3), "diagnostic")],
)
.unwrap();
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.selection_count(), 2);
assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "diagnostic");
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
}
#[test]
fn hidden_pane_interactions_cannot_reopen_or_dismiss_focused_notifications() {
let mut editor = editor();
let hidden = editor.pane_id();
editor.execute("pane.split_vertical");
editor.set_message("focused");
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
editor.with_pane(hidden, |pane| pane.set_message("background"));
assert_eq!(editor.message(), "focused");
assert!(!editor.notification_visible());
editor.with_render_pane(hidden, |pane| {
assert!(pane.message().is_empty());
});
assert!(!editor.notification_visible());
editor.set_message("focused");
editor.with_pane(hidden, |pane| {
pane.set_message("background");
pane.handle(Input::Escape);
});
assert!(editor.notification_visible());
assert_eq!(editor.message(), "focused");
editor.execute("tab.new");
editor.set_message("new tab");
editor.handle(Input::Escape);
editor.with_pane(hidden, |pane| pane.set_message("hidden tab"));
assert_eq!(editor.message(), "new tab");
assert!(!editor.notification_visible());
}
#[test]
fn buffer_activation_preserves_last_operation_and_dismissal() {
let directory = Directory::new();
let path = directory.path("opened.txt");
fs::write(&path, "text").unwrap();
let mut editor = editor();
editor.set_message("Written");
editor.execute(&format!("edit {}", path.display()));
assert_eq!(editor.message(), "Written");
editor.handle(Input::Escape);
for command in ["buffer 1", "bnext", "bprev", "buffer 1"] {
editor.execute(command);
assert_eq!(editor.message(), "Written");
assert!(!editor.notification_visible());
}
editor.execute("buffer 999");
assert!(editor.message().contains("No buffer"));
assert!(editor.notification_visible());
}
#[test]
fn native_lsp_cache_is_independent_presentation_metadata() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("document".into())])
.unwrap();
type_text(&mut editor, "vl");
editor.set_message("Written");
editor.notification_visible = false;
editor
.set_label("lsp".into(), "configured label".into())
.unwrap();
let targets = editor.active.view.targets.clone();
let revision = editor.text_revision();
let history = (editor.active.undo.len(), editor.active.redo.len());
assert!(editor.lsp_names().is_empty());
assert_eq!(editor.lsp_label(), "");
assert!(editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()]));
assert_eq!(editor.lsp_names(), ["rust-analyzer", "界 server"]);
assert_eq!(editor.lsp_label(), "rust-analyzer + 界 server");
assert!(!editor.set_lsp_names(vec!["rust-analyzer".into(), "界 server".into()]));
assert!(editor.set_lsp_names(vec!["unsafe\nname".into()]));
assert_eq!(editor.lsp_label(), "unsafe�name");
assert!(editor.set_lsp_names(Vec::new()));
assert_eq!(editor.lsp_label(), "");
assert_eq!(editor.label("lsp"), "configured label");
assert_eq!(editor.message(), "Written");
assert!(!editor.notification_visible());
assert_eq!(editor.mode(), Mode::Visual);
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
(editor.active.undo.len(), editor.active.redo.len()),
history
);
}
#[test]
fn pane_focus_remaps_do_not_steal_printable_picker_filter_input() {
let directory = Directory::new();
@@ -9571,6 +9794,38 @@ mod tests {
assert!(editor.diff_interacting());
}
#[test]
fn diff_documents_dismiss_notifications_but_files_and_selectors_keep_escape() {
let directory = Directory::new();
let mut editor = editor();
historical_diff(
&mut editor,
&directory.0,
vec!["before".into(), "second".into()],
vec!["after".into(), "second".into()],
);
set_carets(&mut editor, &[(0, 1), (1, 1)], 1);
editor.set_message("operation");
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.selection_count(), 2);
assert!(editor.diff_interacting());
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
editor.set_message("operation");
editor.diff.as_mut().unwrap().focus = Focus::Files;
editor.handle(Input::Escape);
assert!(editor.notification_visible());
assert_ne!(editor.diff().unwrap().focus, Focus::Files);
editor.execute("diff.close");
editor.execute("diff");
editor.handle(Input::Char('q'));
assert_eq!(editor.diff().unwrap().query, "q");
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().query, "");
assert!(editor.notification_visible());
}
#[test]
fn readonly_native_panes_allow_selection_yank_search_and_multicarets() {
for focus in [Focus::Before, Focus::After] {
@@ -11451,6 +11706,7 @@ mod tests {
);
editor.handle(Input::Escape);
assert!(editor.hover().is_none());
assert!(editor.notification_visible());
assert_eq!(editor.active.view.targets, targets);
assert_eq!(editor.active.view.primary, primary);
assert_eq!(editor.context().text, text);
@@ -11461,6 +11717,9 @@ mod tests {
);
assert_eq!(editor.mode(), Mode::Normal);
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.active.view.targets, targets);
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
editor.show_hover("documentation".into()).unwrap();
editor.handle(Input::Char('i'));
@@ -14211,6 +14470,11 @@ mod tests {
assert_eq!(editor.count(), None);
assert_eq!(editor.sequence_pending(), None);
editor.handle(Input::Escape);
if !visual {
assert!(!editor.notification_visible());
assert_eq!(editor.selection_count(), 2);
editor.handle(Input::Escape);
}
assert_eq!(editor.mode(), Mode::Normal);
assert_eq!(editor.selection_count(), if visual { 2 } else { 1 });
}
@@ -16901,6 +17165,10 @@ mod tests {
assert_eq!(editor.context().text, "ab\nxyzq");
editor.handle(Input::Char('u'));
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
assert_eq!(editor.selection_count(), 2);
editor.handle(Input::Escape);
assert_eq!(editor.selection_count(), 1);
let before = editor.snapshot();
let undo = editor.active.undo.len();
let redo = editor.active.redo.len();
@@ -17979,6 +18247,8 @@ mod tests {
fn plugin_effects_validate_before_any_mutation_and_group_undo() {
let mut editor = editor();
editor.set_message("unchanged");
editor.handle(Input::Escape);
assert!(!editor.notification_visible());
editor
.set_label("battery".into(), "unchanged".into())
.unwrap();
@@ -17996,6 +18266,7 @@ mod tests {
);
assert_eq!(editor.context().text, "");
assert_eq!(editor.message(), "unchanged");
assert!(!editor.notification_visible());
assert_eq!(editor.label("battery"), "unchanged");
assert!(!editor.dirty());
assert!(
@@ -18024,6 +18295,7 @@ mod tests {
.unwrap();
assert_eq!(editor.context().text, "one\ntwo");
assert_eq!(editor.message(), "ok");
assert!(editor.notification_visible());
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "");
assert_eq!(editor.label("battery"), "updated");
+31
View File
@@ -60,6 +60,7 @@ struct Contribution {
}
enum Output {
Ready(String),
Update(PluginUpdate),
Diagnostics {
snapshot: Arc<DocumentSnapshot>,
@@ -77,6 +78,7 @@ struct Worker {
failure: Receiver<String>,
cancelled: Arc<AtomicBool>,
retired: bool,
name: Option<String>,
thread: Option<thread::JoinHandle<()>>,
}
@@ -145,6 +147,20 @@ impl Lsp {
!self.configs.is_empty()
}
/// Ready servers assigned to this document, in configured stack order.
pub fn active_server_names(&self, buffer_id: usize) -> impl Iterator<Item = &str> {
self.documents
.get(&buffer_id)
.into_iter()
.flat_map(|doc| &doc.servers)
.filter_map(|key| {
self.servers
.get(key)
.filter(|worker| !worker.retired)
.and_then(|worker| worker.name.as_deref())
})
}
pub fn notify_document(&mut self, event: &DocumentEvent) -> Result<(), String> {
if !self.is_enabled() {
return Ok(());
@@ -334,6 +350,7 @@ impl Lsp {
break;
};
match output {
Output::Ready(name) => worker.name = Some(name),
Output::Update(update) => self.updates.push(update),
Output::Diagnostics { snapshot, items } => {
let Some(doc) = self.documents.get_mut(&snapshot.id).filter(|doc| {
@@ -485,6 +502,7 @@ impl Lsp {
failure,
cancelled,
retired: false,
name: None,
thread,
},
);
@@ -552,6 +570,7 @@ impl Lsp {
return;
}
worker.retired = true;
worker.name = None;
worker.cancelled.store(true, Ordering::Release);
for (id, doc) in documents {
if doc.servers.first() == Some(key) {
@@ -1232,6 +1251,18 @@ impl Runtime {
Some(json!({"settings": self.config.settings})),
)?;
self.ready = true;
let name = result["serverInfo"]["name"]
.as_str()
.filter(|name| !name.chars().any(char::is_control))
.map(str::trim)
.filter(|name| !name.is_empty())
.unwrap_or_else(|| {
Path::new(&self.config.command[0])
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(&self.config.command[0])
});
self.send(Output::Ready(clean(name)))?;
let documents = std::mem::take(&mut self.documents);
for (id, mut doc) in documents {
self.open(&mut doc)?;
-1
View File
@@ -1220,7 +1220,6 @@ end
&crate::chrome::SegmentConfig::default()
);
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}");
}
+21 -5
View File
@@ -251,9 +251,7 @@ impl EditorSession {
));
}
}
if notices.is_empty() {
editor.set_message("Ready • --no-config uses default keybindings");
} else {
if !notices.is_empty() {
editor.set_message(notices.join(" | "));
}
let mut session = Self {
@@ -512,7 +510,7 @@ impl EditorSession {
}
if !self.lsp.is_enabled() && !self.plugins.values().any(RubyPlugin::is_async) {
self.documents.clear();
return false;
return self.refresh_lsp_names();
}
let mut events = Vec::new();
if !self.editor.should_quit() {
@@ -604,7 +602,25 @@ impl EditorSession {
for (owner, error) in failures {
self.retire(&owner, &error);
}
changed
changed | self.refresh_lsp_names()
}
fn refresh_lsp_names(&mut self) -> bool {
let buffer_id = self.editor.buffer_id();
if self.lsp.active_server_names(buffer_id).eq(self
.editor
.lsp_names()
.iter()
.map(String::as_str))
{
return false;
}
self.editor.set_lsp_names(
self.lsp
.active_server_names(buffer_id)
.map(str::to_owned)
.collect(),
)
}
fn source_is_current(&self, source: InvocationSource) -> bool {
+799 -331
View File
File diff suppressed because it is too large Load Diff