From a3658d5e97df73b0ac37e478bbb24f1d00ec3f1a Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 7 Jul 2026 17:58:02 +0200 Subject: [PATCH] fix: eliminate alt-screen flash when returning from repo/project detail The hub hands its live terminal to detail/project_detail on open (already flash-free going in), but both sub-views unconditionally tore the terminal down on exit; the hub's next loop iteration then always called tui::setup() again, producing a real LeaveAlternateScreen -> (plain terminal visible for one frame) -> EnterAlternateScreen blink on every trip back. - detail::run / project_detail::run now return the live terminal on a clean exit instead of tearing it down (only torn down if the inner loop itself errors); hub::tui_phase accepts an already-live terminal and skips its own setup() when given one, threading it across loop iterations - project_detail's own nested repo-detail call (Enter on its Repos tab) had the same issue one level down, fixed the same way via Option so the terminal can be taken out for the nested call and put back - verified via raw pty byte counts: two full hub<->detail round trips now show zero EnterAlternateScreen/LeaveAlternateScreen pairs in between, vs. one pair per trip before --- src/detail/mod.rs | 15 ++++++++----- src/hub/keys.rs | 12 ++++++++-- src/hub/mod.rs | 13 ++++++++--- src/hub/project_detail.rs | 46 +++++++++++++++++++++++++++++---------- 4 files changed, 64 insertions(+), 22 deletions(-) diff --git a/src/detail/mod.rs b/src/detail/mod.rs index d4ed514..15eea9d 100644 --- a/src/detail/mod.rs +++ b/src/detail/mod.rs @@ -38,12 +38,16 @@ use settings_io::{compute_activity, poll_api_rx, poll_prs_issues_rx, poll_settin /// Opens the detail view for `summary`. Reuses an already-live terminal /// handed over by the hub when given one (avoids a flash of the alternate -/// screen toggling off and back on), otherwise sets up a fresh one. +/// screen toggling off and back on), otherwise sets up a fresh one. On a +/// normal exit (back to the hub), hands the still-live terminal back to the +/// caller instead of tearing it down — the hub reuses it for its own next +/// frame, so the alternate screen never toggles off in between. Only torn +/// down here if `run_detail_loop` itself errors. pub async fn run( summary: RepoSummary, cfg: &AppConfig, maybe_term: Option, -) -> Result<()> { +) -> Result { let path = summary.path.clone(); let name = summary.name.clone(); @@ -147,9 +151,10 @@ pub async fn run( }; // Main loop — terminal stays alive the whole time. - let result = run_detail_loop(&mut st, &mut terminal).await; - crate::tui::teardown(terminal)?; - result + match run_detail_loop(&mut st, &mut terminal).await { + Ok(()) => Ok(terminal), + Err(e) => { crate::tui::teardown(terminal)?; Err(e) } + } } async fn run_detail_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> { diff --git a/src/hub/keys.rs b/src/hub/keys.rs index 2e1befc..31b6dfa 100644 --- a/src/hub/keys.rs +++ b/src/hub/keys.rs @@ -148,8 +148,16 @@ pub fn maybe_load_activity(state: &mut HubState) { /// event, and routes it to whichever input mode is currently active. Modal/ /// confirm/filter input short-circuit here (they're loop control flow, not /// part of the global keymap); everything else goes to `keys_dispatch`. -pub fn tui_phase(state: &mut HubState) -> Result<(PhaseResult, Option)> { - let mut terminal = crate::tui::setup()?; +/// +/// `existing_term` lets the caller hand back a terminal a sub-view (repo/ +/// project detail) just returned instead of tearing down and setting up a +/// fresh one — the previous "leave alt screen, blink the real terminal, +/// re-enter" was visible for a frame every time detail handed control back. +pub fn tui_phase(state: &mut HubState, existing_term: Option) -> Result<(PhaseResult, Option)> { + let mut terminal = match existing_term { + Some(t) => t, + None => crate::tui::setup()?, + }; let result = (|| -> Result { loop { state.poll_incoming(); diff --git a/src/hub/mod.rs b/src/hub/mod.rs index 3204347..e731ab8 100644 --- a/src/hub/mod.rs +++ b/src/hub/mod.rs @@ -174,8 +174,13 @@ pub async fn run_overview_tui( log }; + // Carried across loop iterations so a terminal handed back by a sub-view + // (repo/project detail) can be reused by the next `tui_phase` call + // instead of tearing down and setting a fresh one up — see `tui_phase`'s + // doc comment for the flash this avoids. + let mut carried_term: Option = None; loop { - let (phase_result, handed_term) = tui_phase(&mut state)?; + let (phase_result, handed_term) = tui_phase(&mut state, carried_term.take())?; if state.quit { session::save(state.tab, state.cur_repo_idx().map(|ri| state.repos[ri].path.clone())); @@ -186,12 +191,14 @@ pub async fn run_overview_tui( PhaseResult::OpenDetail => { state.open_detail = false; if let Some(ri) = state.cur_repo_idx() { - let _ = crate::status::detail::run(state.repos[ri].clone(), cfg, handed_term).await; + if let Ok(term) = crate::status::detail::run(state.repos[ri].clone(), cfg, handed_term).await { + carried_term = Some(term); + } } else if let Some(pi) = state.cur_proj_idx() { let repo_indices = state.project_repos(pi); let proj_repos: Vec = repo_indices.iter().filter_map(|&ri| state.repos.get(ri)).cloned().collect(); - project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?; + carried_term = Some(project_detail::run(state.projects[pi].clone(), proj_repos, cfg, handed_term).await?); } // Detail view can rename/delete/archive repos or change branches — // rescan so the hub's list reflects whatever just happened, instead diff --git a/src/hub/project_detail.rs b/src/hub/project_detail.rs index 26963b6..5d22a87 100644 --- a/src/hub/project_detail.rs +++ b/src/hub/project_detail.rs @@ -109,28 +109,41 @@ impl St { /// Runs the project detail sub-view until the user backs out. Reuses the /// caller's terminal handle when given one (avoids an alternate-screen -/// flash when entering from the hub), otherwise sets up its own. -pub async fn run(project: Project, repos: Vec, cfg: &AppConfig, maybe_term: Option) -> Result<()> { - let mut terminal = match maybe_term { +/// flash when entering from the hub), otherwise sets up its own. On a +/// normal exit, hands the still-live terminal back to the caller instead of +/// tearing it down, so the hub can reuse it for its own next frame without +/// the alternate screen toggling off in between; only torn down here if +/// `run_loop` itself errors. +pub async fn run(project: Project, repos: Vec, cfg: &AppConfig, maybe_term: Option) -> Result { + // `Option` (rather than a bare `Tty`) so the Enter-on-Repos branch below + // can `take()` it out for the nested `crate::detail::run` call (which + // needs to own the terminal to hand it further down) and put the + // (possibly-recreated) terminal back afterwards, without ever tearing + // down and setting up a fresh one in between. + let mut terminal = Some(match maybe_term { Some(t) => t, None => crate::tui::setup()?, - }; + }); let mut st = St::new(project, repos, cfg.clone()); // Spawn commits aggregation immediately spawn_commits(&mut st); - let result = run_loop(&mut st, &mut terminal).await; - crate::tui::teardown(terminal)?; - result + match run_loop(&mut st, &mut terminal).await { + Ok(()) => Ok(terminal.expect("terminal always present after a clean run_loop exit")), + Err(e) => { + if let Some(t) = terminal.take() { crate::tui::teardown(t)?; } + Err(e) + } + } } -async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> { +async fn run_loop(st: &mut St, terminal: &mut Option) -> Result<()> { loop { poll_commits(st); poll_activity(st); - terminal.draw(|f| super::project_detail_render::draw(f, st))?; + terminal.as_mut().expect("terminal present during run_loop").draw(|f| super::project_detail_render::draw(f, st))?; let event = crate::tui::next_event(250)?; let key = match event { Some(Event::Key(k)) => k, _ => continue }; @@ -182,13 +195,13 @@ async fn run_loop(st: &mut St, terminal: &mut crate::tui::Tty) -> Result<()> { continue; } - handle_key(key.code, st).await; + handle_key(key.code, st, terminal).await; if st.quit { break; } } Ok(()) } -async fn handle_key(code: KeyCode, st: &mut St) { +async fn handle_key(code: KeyCode, st: &mut St, terminal: &mut Option) { st.status_msg = None; match code { KeyCode::Char('q') | KeyCode::Esc => { st.quit = true; } @@ -233,7 +246,16 @@ async fn handle_key(code: KeyCode, st: &mut St) { KeyCode::Enter => match st.tab { ProjectTab::Repos => { if let Some(r) = st.repos.get(st.repo_cursor).cloned() { - let _ = crate::detail::run(r, &st.cfg, None).await; + // Hand our live terminal down (no flash), then take back + // whatever it returns instead of tearing it down here. + let handed = terminal.take(); + match crate::detail::run(r, &st.cfg, handed).await { + Ok(t) => *terminal = Some(t), + // detail::run already tore its (our) terminal down on + // error — nothing left to draw to, so back out clean + // instead of panicking on the next frame's `.expect`. + Err(_) => st.quit = true, + } } } ProjectTab::Todos => {