diff --git a/src/app/api_levels.rs b/src/app/api_levels.rs index ac56918..e2cb4c3 100644 --- a/src/app/api_levels.rs +++ b/src/app/api_levels.rs @@ -74,6 +74,7 @@ impl App { } } } + state.needs_render = true; }); } @@ -186,6 +187,7 @@ impl App { api_mgmt.install_progress = Some(progress); } } + state.needs_render = true; }); }) .await; @@ -199,6 +201,7 @@ impl App { eta_seconds: None, }); } + state.needs_render = true; } tokio::time::sleep(API_INSTALLATION_COMPLETION_DELAY).await; @@ -210,6 +213,7 @@ impl App { api_mgmt.install_progress = None; api_mgmt.error_message = Some(format!("Failed to install: {error}")); } + state.needs_render = true; } else { let mut state = state_clone.lock().await; if let Some(ref mut api_mgmt) = state.api_level_management { @@ -248,6 +252,7 @@ impl App { } } } + state.needs_render = true; }); } }); @@ -310,6 +315,7 @@ impl App { "Failed to uninstall: {}", last_error.unwrap_or_else(|| anyhow::anyhow!("Unknown error")) )); + state.needs_render = true; } { @@ -337,6 +343,7 @@ impl App { } } } + state.needs_render = true; }); }); } diff --git a/src/app/background.rs b/src/app/background.rs index a3bdc9b..169c058 100644 --- a/src/app/background.rs +++ b/src/app/background.rs @@ -70,6 +70,7 @@ impl App { state.android_devices = android_devices; state.is_loading = false; state.mark_refreshed(); + state.mark_dirty(); let should_update_details = state.active_panel == Panel::Android && !state.android_devices.is_empty() @@ -140,6 +141,7 @@ impl App { Ok(ios_devices) => { let mut state = state_clone.lock().await; state.ios_devices = ios_devices; + state.mark_dirty(); let should_update_details = state.active_panel == Panel::Ios && !state.ios_devices.is_empty() diff --git a/src/app/create_device.rs b/src/app/create_device.rs index ff89e50..93dfdfe 100644 --- a/src/app/create_device.rs +++ b/src/app/create_device.rs @@ -312,6 +312,7 @@ impl App { let mut state = state_clone.lock().await; state.create_device_form.creation_status = Some(format!("Creating device '{device_name_for_display}'...")); + state.needs_render = true; } let result = match active_panel { @@ -335,6 +336,7 @@ impl App { let mut state = state_clone.lock().await; state.create_device_form.creation_status = Some("Finalizing...".to_string()); + state.needs_render = true; } match active_panel { diff --git a/src/app/mod.rs b/src/app/mod.rs index d7528dc..9cc6e61 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -35,6 +35,7 @@ use crate::{ }; use anyhow::Result; use crossterm::event::{self, Event as CrosstermEvent}; +use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate}; use ratatui::{backend::CrosstermBackend, Terminal}; use std::sync::Arc; use tokio::sync::Mutex; @@ -204,10 +205,17 @@ impl App { continue; } - // Priority 2: Render UI after processing input for immediate visual feedback + // Priority 2: Render UI — only when state has changed or input was processed. + // BeginSynchronizedUpdate/EndSynchronizedUpdate (DEC PM 2026) prevents partial-frame + // flicker on supporting terminals by buffering all writes until EndSynchronizedUpdate. { let mut state = self.state.lock().await; - terminal.draw(|f| ui::render::draw_app(f, &mut state, &ui::Theme::dark()))?; + if events_processed > 0 || state.needs_render { + state.needs_render = false; + crossterm::queue!(terminal.backend_mut(), BeginSynchronizedUpdate)?; + terminal.draw(|f| ui::render::draw_app(f, &mut state, &ui::Theme::dark()))?; + crossterm::execute!(terminal.backend_mut(), EndSynchronizedUpdate)?; + } } // Priority 3: Handle background tasks (less frequently to avoid blocking input) diff --git a/src/app/refresh.rs b/src/app/refresh.rs index 71e207a..063cd70 100644 --- a/src/app/refresh.rs +++ b/src/app/refresh.rs @@ -102,6 +102,7 @@ impl App { state.is_loading = false; state.mark_refreshed(); + state.mark_dirty(); let need_detail_update = if let Some(ref started_name) = device_started { match state.active_panel { @@ -195,6 +196,7 @@ impl App { state.is_loading = false; state.mark_refreshed(); + state.mark_dirty(); Ok(()) } diff --git a/src/app/state/details.rs b/src/app/state/details.rs index b583d9b..663a65b 100644 --- a/src/app/state/details.rs +++ b/src/app/state/details.rs @@ -88,6 +88,7 @@ impl AppState { details.device_path.is_some() ); self.cached_device_details = Some(details); + self.needs_render = true; } /// Clears all cached device details. diff --git a/src/app/state/logs.rs b/src/app/state/logs.rs index f7ad470..b86ab0c 100644 --- a/src/app/state/logs.rs +++ b/src/app/state/logs.rs @@ -34,6 +34,8 @@ impl AppState { let total_logs = self.device_logs.len(); self.log_scroll_offset = total_logs.saturating_sub(1); } + + self.needs_render = true; } /// Clears all device logs from memory. @@ -159,6 +161,8 @@ impl AppState { }; } } + + self.needs_render = true; } } @@ -177,6 +181,8 @@ impl AppState { }; } } + + self.needs_render = true; } } } diff --git a/src/app/state/mod.rs b/src/app/state/mod.rs index a5fb575..fe93ed3 100644 --- a/src/app/state/mod.rs +++ b/src/app/state/mod.rs @@ -104,6 +104,9 @@ pub struct AppState { pub ios_scroll_offset: usize, /// API level management dialog state (when dialog is open) pub api_level_management: Option, + /// Dirty flag: set by background tasks and state mutations, cleared after each render. + /// Prevents unconditional 125 fps redraws when nothing has changed. + pub needs_render: bool, } impl Default for AppState { @@ -140,6 +143,7 @@ impl Default for AppState { android_scroll_offset: 0, ios_scroll_offset: 0, api_level_management: None, + needs_render: true, } } } @@ -150,6 +154,12 @@ impl AppState { Self::default() } + /// Marks the UI as needing a redraw. + /// Call this from any background task that mutates state visible in the UI. + pub fn mark_dirty(&mut self) { + self.needs_render = true; + } + // --- Mode predicates --- /// Returns true if the app is in normal mode. @@ -224,6 +234,8 @@ impl AppState { while self.notifications.len() > self.max_notifications { self.notifications.pop_front(); } + + self.needs_render = true; } /// Adds a success notification with green color. @@ -248,7 +260,11 @@ impl AppState { /// Removes notifications that have exceeded their auto-dismiss duration. pub fn dismiss_expired_notifications(&mut self) { + let before = self.notifications.len(); self.notifications.retain(|n| !n.should_dismiss()); + if self.notifications.len() != before { + self.needs_render = true; + } } /// Clears all notifications from the queue.