From ba8c41f423c9a8f0cb14ff19baa6f538c95e52bf Mon Sep 17 00:00:00 2001 From: Alexsander Falcucci Date: Wed, 12 Aug 2026 01:03:06 +0200 Subject: [PATCH 1/2] handle neovim bufwrite progress events (#3546) after some debugging, stable and nightly currently encode buffer writes differently meaning that stable has something like: { data = ..., id = "bufwrite", percent = 0, source = "nvim", status = "running", text = { '[file-path]' }, title = "" } against the current nightly { data = ..., id = 'nvim.bufwrite "[file-path]"', source = "nvim", status = "running", text = { '"[file-path]" ' }, title = "" } so missing percentages now finish the active progress item instead of starting a 0% animation, while determinate progress continues to animate by id. for example now we will show the progress bar only when a neovim producer (a plugin or any process event that uses nvim_echo) sends a real determinate percent. vim.api.nvim_echo({ { "my-progress-bar." } }, true, { kind = "progress", id = "neovide-task", source = "plugin-name", percent = 25, status = "running", }) --- lua/init.lua | 15 +++--- src/bridge/events.rs | 39 +++++++++++++--- src/renderer/mod.rs | 2 +- src/renderer/progress_bar.rs | 89 ++++++++++++++++++++++++++++++++---- src/window/mod.rs | 25 +++++++++- src/window/window_wrapper.rs | 21 +++++++-- 6 files changed, 160 insertions(+), 31 deletions(-) diff --git a/lua/init.lua b/lua/init.lua index 0848d41d..f906f9bf 100644 --- a/lua/init.lua +++ b/lua/init.lua @@ -430,18 +430,19 @@ vim.api.nvim_create_autocmd("OptionSet", { notify_intro_state() -pcall(vim.api.nvim_create_autocmd, 'Progress', { - group = vim.api.nvim_create_augroup('NeovideProgressBar', { clear = true }), - desc = 'Forward progress events to Neovide', +pcall(vim.api.nvim_create_autocmd, "Progress", { + group = vim.api.nvim_create_augroup("NeovideProgressBar", { clear = true }), + desc = "Forward progress events to Neovide", callback = function(ev) - if ev.data and ev.data.status == 'running' then + if ev.data then progress_bar({ - percent = ev.data.percent or 0, + id = ev.data.id, + source = ev.data.source, + status = ev.data.status, + percent = ev.data.percent, title = ev.data.title or "", message = ev.data.message or "", }) - else - progress_bar({ percent = 100 }) end end, }) diff --git a/src/bridge/events.rs b/src/bridge/events.rs index 0fc211f8..3b223831 100644 --- a/src/bridge/events.rs +++ b/src/bridge/events.rs @@ -12,7 +12,7 @@ use strum::AsRefStr; use super::RestartDetails; use crate::{ editor::{Colors, CursorMode, CursorShape, Style, UnderlineStyle}, - window::UserEvent, + window::{ProgressBarUpdate, UserEvent}, }; #[derive(Clone, Debug)] @@ -1144,15 +1144,40 @@ pub fn parse_redraw_event(event_value: Value) -> Result> { Ok(parsed_events) } +fn parse_map_field<'a>(map: &'a [(Value, Value)], key: &str) -> Option<&'a Value> { + map.iter().find_map(|(name, value)| (name.as_str() == Some(key)).then_some(value)) +} + +fn parse_progress_id(value: &Value) -> Option { + value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.as_i64().map(|value| value.to_string())) + .or_else(|| value.as_u64().map(|value| value.to_string())) +} + +fn parse_progress_string(value: &Value) -> Option { + value.as_str().map(ToOwned::to_owned) +} + +fn parse_progress_percent(value: &Value) -> Option { + value + .as_f64() + .map(|value| value as f32) + .or_else(|| value.as_i64().map(|value| value as f32)) + .or_else(|| value.as_u64().map(|value| value as f32)) +} + pub fn parse_progress_bar_event(value: Option<&Value>) -> Option { let map = value.filter(|v| matches!(v, Value::Map(_)))?.as_map()?; - let percent = map - .iter() - .find(|(key, _)| key.as_str() == Some("percent")) - .and_then(|(_, value)| value.as_f64()) - .unwrap_or(0.0) as f32; + let update = ProgressBarUpdate { + id: parse_map_field(map, "id").and_then(parse_progress_id), + source: parse_map_field(map, "source").and_then(parse_progress_string), + status: parse_map_field(map, "status").and_then(parse_progress_string), + percent: parse_map_field(map, "percent").and_then(parse_progress_percent), + }; - Some(UserEvent::ShowProgressBar { percent }) + Some(UserEvent::ShowProgressBar { update }) } #[cfg(test)] diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs index 4920a008..73300e83 100644 --- a/src/renderer/mod.rs +++ b/src/renderer/mod.rs @@ -488,7 +488,7 @@ impl Renderer { let progress_bar_settings = self.settings.get::(); self.progress_bar.animate(&progress_bar_settings, dt); - animating |= self.progress_bar.is_animating(); + animating |= self.progress_bar.is_active(); animating } diff --git a/src/renderer/progress_bar.rs b/src/renderer/progress_bar.rs index 12001b59..000482f6 100644 --- a/src/renderer/progress_bar.rs +++ b/src/renderer/progress_bar.rs @@ -4,6 +4,9 @@ use crate::{renderer::GridRenderer, settings::ParseFromValue}; use neovide_derive::SettingGroup; use skia_safe::{Canvas, Color4f, Paint, Rect}; +/// Minimum active time required before the progress earns to be shown. +const REVEAL_DELAY: f32 = 0.1; + #[derive(Clone, SettingGroup)] #[setting_prefix = "progress_bar"] pub struct ProgressBarSettings { @@ -21,36 +24,105 @@ impl Default for ProgressBarSettings { enum ProgressBarState { Idle, + /// Progress has started, but the bar has not been shown yet. + /// + /// it could happen that a producer has a very short progress or just sent + /// only a final completion update showing updates that could take a 0-100 + /// animation bar after its work has been finished or by an unknown behavior, + /// adding actually only noise without giving useful user progress feedback. + /// + /// we keep the bar hidden until progress remains active based on a + /// REVEAL_DELAY making it much more meaninful to show progress that + /// has been earned to be shown instead. + Pending { + elapsed: f32, + }, Animating, - Completing { completion_time: Instant }, + Completing { + completion_time: Instant, + }, } pub struct ProgressBar { current_percent: f32, target_percent: f32, + id: Option, state: ProgressBarState, } impl ProgressBar { pub fn new() -> Self { - Self { current_percent: 0.0, target_percent: 0.0, state: ProgressBarState::Idle } + Self { current_percent: 0.0, target_percent: 0.0, id: None, state: ProgressBarState::Idle } } - pub fn is_animating(&self) -> bool { + pub fn is_active(&self) -> bool { !matches!(self.state, ProgressBarState::Idle) } - pub fn start(&mut self, percent: f32) { + fn is_visible(&self) -> bool { + matches!(self.state, ProgressBarState::Animating | ProgressBarState::Completing { .. }) + } + + fn reset(&mut self) { + self.current_percent = 0.0; + self.target_percent = 0.0; + self.id = None; + self.state = ProgressBarState::Idle; + } + + fn set_target_percent(&mut self, percent: f32) { self.target_percent = percent.clamp(0.0, 100.0); if self.target_percent < self.current_percent { self.current_percent = self.target_percent; } + } + + pub fn start(&mut self, id: Option<&str>, percent: f32) { + if percent >= 100.0 { + self.finish(id); + return; + } + + let is_idle = matches!(self.state, ProgressBarState::Idle); + let is_completing = matches!(self.state, ProgressBarState::Completing { .. }); + + self.id = id.map(ToOwned::to_owned); + self.set_target_percent(percent); + + if is_idle { + self.state = ProgressBarState::Pending { elapsed: 0.0 }; + } else if is_completing { + self.state = ProgressBarState::Animating; + } + } + + pub fn finish(&mut self, id: Option<&str>) { + let current_id = self.id.as_deref(); + let id_mismatch = id.zip(current_id).is_some_and(|(id, current_id)| id != current_id); + + if !self.is_active() || id_mismatch { + return; + } + + if matches!(&self.state, ProgressBarState::Pending { .. }) { + self.reset(); + return; + } + + self.id = id.or(current_id).map(ToOwned::to_owned); + self.set_target_percent(100.0); self.state = ProgressBarState::Animating; } pub fn animate(&mut self, settings: &ProgressBarSettings, dt: f32) { - match &self.state { + match &mut self.state { ProgressBarState::Idle => {} + ProgressBarState::Pending { elapsed } => { + *elapsed += dt; + if *elapsed >= REVEAL_DELAY { + self.state = ProgressBarState::Animating; + } + } ProgressBarState::Animating => { if self.current_percent < self.target_percent { self.current_percent += settings.animation_speed * dt; @@ -63,10 +135,7 @@ impl ProgressBar { } ProgressBarState::Completing { completion_time } => { if completion_time.elapsed().as_secs_f32() > settings.hide_delay { - self.state = ProgressBarState::Idle; - // Reset percents for next time - self.current_percent = 0.0; - self.target_percent = 0.0; + self.reset(); } } } @@ -79,7 +148,7 @@ impl ProgressBar { grid_renderer: &GridRenderer, grid_size: crate::units::GridSize, ) { - if !self.is_animating() || !settings.enabled { + if !self.is_visible() || !settings.enabled { return; } diff --git a/src/window/mod.rs b/src/window/mod.rs index 11375b14..33bcfccc 100644 --- a/src/window/mod.rs +++ b/src/window/mod.rs @@ -74,6 +74,29 @@ pub struct Pressure { stage: i64, } +#[derive(Clone, Debug, PartialEq)] +pub struct ProgressBarUpdate { + pub id: Option, + pub source: Option, + pub status: Option, + pub percent: Option, +} + +impl ProgressBarUpdate { + /// neovim stable and nightly encode the same save event differently. + /// for example stable uses id = "bufwrite" with percent = 0 while nightly uses + /// id = "nvim.bufwrite [file]" without a percent field. + /// A determinate percentage on either shape is not treated as built-in save progress. + pub fn is_neovim_bufwrite(&self) -> bool { + self.source.as_deref() == Some("nvim") + && match self.id.as_deref() { + Some("bufwrite") => self.percent == Some(0.0), // stable + Some(id) if id.starts_with("nvim.bufwrite ") => self.percent.is_none(), // nightly + _ => false, + } + } +} + #[cfg(target_os = "macos")] #[derive(Clone, Debug, PartialEq)] pub enum ForceClickKind { @@ -158,7 +181,7 @@ pub enum UserEvent { }, NeovimRestart(RestartDetails), ShowProgressBar { - percent: f32, + update: ProgressBarUpdate, }, #[cfg(target_os = "macos")] CreateWindow, diff --git a/src/window/window_wrapper.rs b/src/window/window_wrapper.rs index 76e77545..822f5843 100644 --- a/src/window/window_wrapper.rs +++ b/src/window/window_wrapper.rs @@ -23,7 +23,8 @@ use approx::AbsDiffEq; use super::settings::CornerPreference; use super::{ EventPayload, EventTarget, KeyboardManager, MessageSelectionEvent, MouseManager, OverlayEvent, - RouteId, UserEvent, WindowCommand, WindowSettings, WindowSettingsChanged, WindowSize, + ProgressBarUpdate, RouteId, UserEvent, WindowCommand, WindowSettings, WindowSettingsChanged, + WindowSize, }; #[cfg(target_os = "macos")] @@ -1243,8 +1244,8 @@ impl WinitWindowWrapper { UserEvent::MacShortcut(command) => { self.handle_mac_shortcut(command); } - UserEvent::ShowProgressBar { percent, .. } => { - self.handle_progress_bar(target, percent); + UserEvent::ShowProgressBar { update } => { + self.handle_progress_bar(target, update); } _ => {} } @@ -2246,13 +2247,23 @@ impl WinitWindowWrapper { } } - fn handle_progress_bar(&mut self, target: EventTarget, percent: f32) { + fn handle_progress_bar(&mut self, target: EventTarget, update: ProgressBarUpdate) { tracy_zone!("handle_progress_bar"); + if update.is_neovim_bufwrite() { + return; + } + let window_ids = self.window_ids_for_target(target); for window_id in window_ids { if let Some(route) = self.routes.get(&window_id) { let mut renderer = route.window.renderer.borrow_mut(); - renderer.progress_bar.start(percent); + match update.percent { + Some(percent) if percent >= 100.0 => { + renderer.progress_bar.finish(update.id.as_deref()); + } + Some(percent) => renderer.progress_bar.start(update.id.as_deref(), percent), + None => renderer.progress_bar.finish(update.id.as_deref()), + } } } } From 92e4d46d86db9a951b1d8e296ed9b52dc33a9e7e Mon Sep 17 00:00:00 2001 From: Alexsander Falcucci Date: Sat, 29 Aug 2026 01:50:58 +0200 Subject: [PATCH 2/2] swap the drained vector out using std::mem::take (#3559) fix clippy errors --- src/window/application.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/window/application.rs b/src/window/application.rs index e39d4880..e477b3d2 100644 --- a/src/window/application.rs +++ b/src/window/application.rs @@ -499,7 +499,7 @@ impl Application { fn process_buffered_draw_commands(&mut self, window_id: WindowId) { let pending_batches = match self.render_states.get_mut(&window_id) { - Some(state) => state.pending_draw_commands.drain(..).collect::>(), + Some(state) => std::mem::take(&mut state.pending_draw_commands), None => return, }; if !pending_batches.is_empty() {