4 Commits

Author SHA1 Message Date
teernisse
bbfcfa2082 fix(tui): bounds-check scope picker selected index
Replace direct indexing (self.projects[self.selected_index - 1]) with
.get() to prevent panic if selected_index is somehow out of bounds.
Falls back to "All Projects" scope when the index is invalid instead
of panicking.
2026-02-18 23:59:11 -05:00
teernisse
45a989637c feat(tui): add per-screen responsive layout helpers
Introduce breakpoint-aware helper functions in layout.rs that
centralize per-screen responsive decisions. Each function maps a
Breakpoint to a screen-specific value, replacing scattered
hardcoded checks across view modules:

- detail_side_panel: show discussions side panel at Lg+
- info_screen_columns: 1 column on Xs/Sm, 2 on Md+
- search_show_project: hide project path column on narrow terminals
- timeline_time_width: compact time on Xs (5), full on Md+ (12)
- who_abbreviated_tabs: shorten tab labels on Xs/Sm
- sync_progress_bar_width: scale progress bar 15→50 with width

All functions are const fn with exhaustive match arms.
Includes 6 unit tests covering every breakpoint variant.
2026-02-18 23:59:04 -05:00
teernisse
1b66b80ac4 style(tui): apply rustfmt and clippy formatting across crate
Mechanical formatting pass to satisfy rustfmt line-width limits and
clippy pedantic/nursery lints. No behavioral changes.

Formatting (rustfmt line wrapping):
- action/sync.rs: multiline tuple destructure, function call args in tests
- state/sync.rs: if-let chain formatting, remove unnecessary Vec collect
- view/sync.rs: multiline array entries, format!(), vec! literals
- view/doctor.rs: multiline floor_char_boundary chain
- view/scope_picker.rs: multiline format!() with floor_char_boundary
- view/stats.rs: multiline render_stat_row call
- view/mod.rs: multiline assert!() in test
- app/update.rs: multiline enum variant destructure
- entity_cache.rs: multiline assert_eq!() with messages
- render_cache.rs: multiline retain() closure
- session.rs: multiline serde_json/File::create/parent() chains

Clippy:
- action/sync.rs: #[allow(clippy::too_many_arguments)] on test helper

Import/module ordering (alphabetical):
- state/mod.rs: move scope_picker mod + pub use to sorted position
- view/mod.rs: move scope_picker, stats, sync mod + use to sorted position
- view/scope_picker.rs: sort use imports (ScopeContext before ScopePickerState)
2026-02-18 23:58:29 -05:00
teernisse
09ffcfcf0f refactor(tui): deduplicate cursor_cell_offset into text_width module
Four view modules (search, command_palette, file_history, trace) each had
their own copy of cursor_cell_offset / text_cell_width for converting a
byte-offset cursor position to a display-column offset. Phase 5 introduced
a proper text_width module with these functions; this commit removes the
duplicates and rewires all call sites to use crate::text_width.

- search.rs: removed local text_cell_width + cursor_cell_offset definitions
- command_palette.rs: removed local cursor_cell_offset definition
- file_history.rs: replaced inline chars().count() cursor calc with import
- trace.rs: replaced inline chars().count() cursor calc with import
2026-02-18 23:58:13 -05:00
18 changed files with 305 additions and 85 deletions

View File

@@ -172,7 +172,15 @@ pub fn fetch_recent_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunI
let run_id: Option<String> = row.get(8)?; let run_id: Option<String> = row.get(8)?;
Ok(( Ok((
id, status, command, started_at, finished_at, items, errors, error, run_id, id,
status,
command,
started_at,
finished_at,
items,
errors,
error,
run_id,
)) ))
}) })
.context("querying sync runs")?; .context("querying sync runs")?;
@@ -265,6 +273,7 @@ mod tests {
.expect("insert project"); .expect("insert project");
} }
#[allow(clippy::too_many_arguments)]
fn insert_sync_run( fn insert_sync_run(
conn: &Connection, conn: &Connection,
started_at: i64, started_at: i64,
@@ -314,8 +323,26 @@ mod tests {
create_sync_schema(&conn); create_sync_schema(&conn);
let now = 1_700_000_000_000_i64; let now = 1_700_000_000_000_i64;
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "succeeded", "sync", 100, 0, None); insert_sync_run(
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "failed", "sync", 50, 2, Some("timeout")); &conn,
now - 60_000,
Some(now - 30_000),
"succeeded",
"sync",
100,
0,
None,
);
insert_sync_run(
&conn,
now - 120_000,
Some(now - 90_000),
"failed",
"sync",
50,
2,
Some("timeout"),
);
let clock = FakeClock::from_ms(now); let clock = FakeClock::from_ms(now);
let result = detect_running_sync(&conn, &clock).unwrap(); let result = detect_running_sync(&conn, &clock).unwrap();
@@ -386,8 +413,26 @@ mod tests {
create_sync_schema(&conn); create_sync_schema(&conn);
let now = 1_700_000_000_000_i64; let now = 1_700_000_000_000_i64;
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "succeeded", "sync", 100, 0, None); insert_sync_run(
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "succeeded", "sync", 200, 0, None); &conn,
now - 120_000,
Some(now - 90_000),
"succeeded",
"sync",
100,
0,
None,
);
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"succeeded",
"sync",
200,
0,
None,
);
let runs = fetch_recent_runs(&conn, 10).unwrap(); let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs.len(), 2); assert_eq!(runs.len(), 2);
@@ -425,7 +470,16 @@ mod tests {
create_sync_schema(&conn); create_sync_schema(&conn);
let now = 1_700_000_000_000_i64; let now = 1_700_000_000_000_i64;
insert_sync_run(&conn, now - 60_000, Some(now - 15_000), "succeeded", "sync", 0, 0, None); insert_sync_run(
&conn,
now - 60_000,
Some(now - 15_000),
"succeeded",
"sync",
0,
0,
None,
);
let runs = fetch_recent_runs(&conn, 10).unwrap(); let runs = fetch_recent_runs(&conn, 10).unwrap();
assert_eq!(runs[0].duration_ms, Some(45_000)); assert_eq!(runs[0].duration_ms, Some(45_000));
@@ -517,8 +571,26 @@ mod tests {
let now = 1_700_000_000_000_i64; let now = 1_700_000_000_000_i64;
insert_project(&conn, 1, "group/repo"); insert_project(&conn, 1, "group/repo");
insert_sync_run(&conn, now - 120_000, Some(now - 90_000), "succeeded", "sync", 150, 0, None); insert_sync_run(
insert_sync_run(&conn, now - 60_000, Some(now - 30_000), "failed", "sync", 50, 2, Some("db locked")); &conn,
now - 120_000,
Some(now - 90_000),
"succeeded",
"sync",
150,
0,
None,
);
insert_sync_run(
&conn,
now - 60_000,
Some(now - 30_000),
"failed",
"sync",
50,
2,
Some("db locked"),
);
let clock = FakeClock::from_ms(now); let clock = FakeClock::from_ms(now);
let overview = fetch_sync_overview(&conn, &clock).unwrap(); let overview = fetch_sync_overview(&conn, &clock).unwrap();
@@ -542,7 +614,16 @@ mod tests {
insert_project(&conn, 1, "group/repo"); insert_project(&conn, 1, "group/repo");
// A completed run. // A completed run.
insert_sync_run(&conn, now - 600_000, Some(now - 570_000), "succeeded", "sync", 200, 0, None); insert_sync_run(
&conn,
now - 600_000,
Some(now - 570_000),
"succeeded",
"sync",
200,
0,
None,
);
// A currently running sync. // A currently running sync.
conn.execute( conn.execute(

View File

@@ -590,7 +590,10 @@ impl LoreApp {
} }
// --- Search --- // --- Search ---
Msg::SearchExecuted { generation, results } => { Msg::SearchExecuted {
generation,
results,
} => {
if self if self
.supervisor .supervisor
.is_current(&TaskKey::LoadScreen(Screen::Search), generation) .is_current(&TaskKey::LoadScreen(Screen::Search), generation)

View File

@@ -154,8 +154,16 @@ mod tests {
// Insert a 4th item: should evict issue(2) (tick 2, lowest). // Insert a 4th item: should evict issue(2) (tick 2, lowest).
cache.put(issue(4), "d"); // tick 5 cache.put(issue(4), "d"); // tick 5
assert_eq!(cache.get(&issue(1)), Some(&"a"), "issue(1) should survive (recently accessed)"); assert_eq!(
assert_eq!(cache.get(&issue(2)), None, "issue(2) should be evicted (LRU)"); cache.get(&issue(1)),
Some(&"a"),
"issue(1) should survive (recently accessed)"
);
assert_eq!(
cache.get(&issue(2)),
None,
"issue(2) should be evicted (LRU)"
);
assert_eq!(cache.get(&issue(3)), Some(&"c"), "issue(3) should survive"); assert_eq!(cache.get(&issue(3)), Some(&"c"), "issue(3) should survive");
assert_eq!(cache.get(&issue(4)), Some(&"d"), "issue(4) just inserted"); assert_eq!(cache.get(&issue(4)), Some(&"d"), "issue(4) just inserted");
} }

View File

@@ -44,6 +44,84 @@ pub const fn show_preview_pane(bp: Breakpoint) -> bool {
} }
} }
// ---------------------------------------------------------------------------
// Per-screen responsive helpers
// ---------------------------------------------------------------------------
/// Whether detail views (issue/MR) should show a side panel for discussions.
///
/// At `Lg`+ widths, enough room exists for a 60/40 or 50/50 split with
/// description on the left and discussions/cross-refs on the right.
#[inline]
pub const fn detail_side_panel(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Lg | Breakpoint::Xl => true,
Breakpoint::Xs | Breakpoint::Sm | Breakpoint::Md => false,
}
}
/// Number of stat columns for the Stats/Doctor screens.
///
/// - `Xs` / `Sm`: 1 column (full-width stacked)
/// - `Md`+: 2 columns (side-by-side sections)
#[inline]
pub const fn info_screen_columns(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 1,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 2,
}
}
/// Whether to show the project path column in search results.
///
/// On narrow terminals, the project path is dropped to give the title
/// more room.
#[inline]
pub const fn search_show_project(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => false,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => true,
}
}
/// Width allocated for the relative-time column in timeline events.
///
/// Narrow terminals get a compact time (e.g., "2h"), wider terminals
/// get the full relative time (e.g., "2 hours ago").
#[inline]
pub const fn timeline_time_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 5,
Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
/// Whether to use abbreviated mode-tab labels in the Who screen.
///
/// On narrow terminals, tabs are shortened to 3-char abbreviations
/// (e.g., "Exp" instead of "Expert") to fit all 5 modes.
#[inline]
pub const fn who_abbreviated_tabs(bp: Breakpoint) -> bool {
match bp {
Breakpoint::Xs | Breakpoint::Sm => true,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => false,
}
}
/// Width of the progress bar in the Sync screen.
///
/// Scales with terminal width to use available space effectively.
#[inline]
pub const fn sync_progress_bar_width(bp: Breakpoint) -> u16 {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 25,
Breakpoint::Md => 35,
Breakpoint::Lg | Breakpoint::Xl => 50,
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -99,4 +177,60 @@ mod tests {
fn test_lore_breakpoints_matches_defaults() { fn test_lore_breakpoints_matches_defaults() {
assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT); assert_eq!(LORE_BREAKPOINTS, Breakpoints::DEFAULT);
} }
// -- Per-screen responsive helpers ----------------------------------------
#[test]
fn test_detail_side_panel() {
assert!(!detail_side_panel(Breakpoint::Xs));
assert!(!detail_side_panel(Breakpoint::Sm));
assert!(!detail_side_panel(Breakpoint::Md));
assert!(detail_side_panel(Breakpoint::Lg));
assert!(detail_side_panel(Breakpoint::Xl));
}
#[test]
fn test_info_screen_columns() {
assert_eq!(info_screen_columns(Breakpoint::Xs), 1);
assert_eq!(info_screen_columns(Breakpoint::Sm), 1);
assert_eq!(info_screen_columns(Breakpoint::Md), 2);
assert_eq!(info_screen_columns(Breakpoint::Lg), 2);
assert_eq!(info_screen_columns(Breakpoint::Xl), 2);
}
#[test]
fn test_search_show_project() {
assert!(!search_show_project(Breakpoint::Xs));
assert!(!search_show_project(Breakpoint::Sm));
assert!(search_show_project(Breakpoint::Md));
assert!(search_show_project(Breakpoint::Lg));
assert!(search_show_project(Breakpoint::Xl));
}
#[test]
fn test_timeline_time_width() {
assert_eq!(timeline_time_width(Breakpoint::Xs), 5);
assert_eq!(timeline_time_width(Breakpoint::Sm), 8);
assert_eq!(timeline_time_width(Breakpoint::Md), 12);
assert_eq!(timeline_time_width(Breakpoint::Lg), 12);
assert_eq!(timeline_time_width(Breakpoint::Xl), 12);
}
#[test]
fn test_who_abbreviated_tabs() {
assert!(who_abbreviated_tabs(Breakpoint::Xs));
assert!(who_abbreviated_tabs(Breakpoint::Sm));
assert!(!who_abbreviated_tabs(Breakpoint::Md));
assert!(!who_abbreviated_tabs(Breakpoint::Lg));
assert!(!who_abbreviated_tabs(Breakpoint::Xl));
}
#[test]
fn test_sync_progress_bar_width() {
assert_eq!(sync_progress_bar_width(Breakpoint::Xs), 15);
assert_eq!(sync_progress_bar_width(Breakpoint::Sm), 25);
assert_eq!(sync_progress_bar_width(Breakpoint::Md), 35);
assert_eq!(sync_progress_bar_width(Breakpoint::Lg), 50);
assert_eq!(sync_progress_bar_width(Breakpoint::Xl), 50);
}
} }

View File

@@ -104,8 +104,7 @@ impl<V> RenderCache<V> {
/// ///
/// After a resize, only entries rendered at the new width are still valid. /// After a resize, only entries rendered at the new width are still valid.
pub fn invalidate_width(&mut self, keep_width: u16) { pub fn invalidate_width(&mut self, keep_width: u16) {
self.entries self.entries.retain(|k, _| k.terminal_width == keep_width);
.retain(|k, _| k.terminal_width == keep_width);
} }
/// Clear the entire cache (theme change — all colors invalidated). /// Clear the entire cache (theme change — all colors invalidated).

View File

@@ -95,8 +95,8 @@ pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionErro
fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?; fs::create_dir_all(parent).map_err(|e| SessionError::Io(e.to_string()))?;
} }
let json = serde_json::to_string_pretty(state) let json =
.map_err(|e| SessionError::Serialize(e.to_string()))?; serde_json::to_string_pretty(state).map_err(|e| SessionError::Serialize(e.to_string()))?;
// Check size before writing. // Check size before writing.
if json.len() as u64 > MAX_SESSION_SIZE { if json.len() as u64 > MAX_SESSION_SIZE {
@@ -112,8 +112,7 @@ pub fn save_session(state: &SessionState, path: &Path) -> Result<(), SessionErro
// Write to temp file, fsync, rename. // Write to temp file, fsync, rename.
let tmp_path = path.with_extension("tmp"); let tmp_path = path.with_extension("tmp");
let mut file = let mut file = fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
fs::File::create(&tmp_path).map_err(|e| SessionError::Io(e.to_string()))?;
file.write_all(payload.as_bytes()) file.write_all(payload.as_bytes())
.map_err(|e| SessionError::Io(e.to_string()))?; .map_err(|e| SessionError::Io(e.to_string()))?;
file.sync_all() file.sync_all()
@@ -179,10 +178,7 @@ pub fn load_session(path: &Path) -> Result<SessionState, SessionError> {
/// Move a corrupt session file to `.quarantine/` instead of deleting it. /// Move a corrupt session file to `.quarantine/` instead of deleting it.
fn quarantine(path: &Path) -> Result<(), SessionError> { fn quarantine(path: &Path) -> Result<(), SessionError> {
let quarantine_dir = path let quarantine_dir = path.parent().unwrap_or(Path::new(".")).join(".quarantine");
.parent()
.unwrap_or(Path::new("."))
.join(".quarantine");
fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?; fs::create_dir_all(&quarantine_dir).map_err(|e| SessionError::Io(e.to_string()))?;
let filename = path let filename = path

View File

@@ -22,13 +22,13 @@ pub mod issue_detail;
pub mod issue_list; pub mod issue_list;
pub mod mr_detail; pub mod mr_detail;
pub mod mr_list; pub mod mr_list;
pub mod scope_picker;
pub mod search; pub mod search;
pub mod stats; pub mod stats;
pub mod sync; pub mod sync;
pub mod sync_delta_ledger; pub mod sync_delta_ledger;
pub mod timeline; pub mod timeline;
pub mod trace; pub mod trace;
pub mod scope_picker;
pub mod who; pub mod who;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@@ -45,12 +45,12 @@ pub use issue_detail::IssueDetailState;
pub use issue_list::IssueListState; pub use issue_list::IssueListState;
pub use mr_detail::MrDetailState; pub use mr_detail::MrDetailState;
pub use mr_list::MrListState; pub use mr_list::MrListState;
pub use scope_picker::ScopePickerState;
pub use search::SearchState; pub use search::SearchState;
pub use stats::StatsState; pub use stats::StatsState;
pub use sync::SyncState; pub use sync::SyncState;
pub use timeline::TimelineState; pub use timeline::TimelineState;
pub use trace::TraceState; pub use trace::TraceState;
pub use scope_picker::ScopePickerState;
pub use who::WhoState; pub use who::WhoState;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -76,12 +76,17 @@ impl ScopePickerState {
project_id: None, project_id: None,
project_name: None, project_name: None,
} }
} else { } else if let Some(project) = self.projects.get(self.selected_index - 1) {
let project = &self.projects[self.selected_index - 1];
ScopeContext { ScopeContext {
project_id: Some(project.id), project_id: Some(project.id),
project_name: Some(project.path.clone()), project_name: Some(project.path.clone()),
} }
} else {
// Out-of-bounds — fall back to "All Projects".
ScopeContext {
project_id: None,
project_name: None,
}
} }
} }

View File

@@ -356,12 +356,12 @@ impl SyncState {
self.bytes_synced = bytes; self.bytes_synced = bytes;
self.items_synced = items; self.items_synced = items;
// Compute actual throughput from elapsed time since sync start. // Compute actual throughput from elapsed time since sync start.
if items > 0 { if items > 0
if let Some(started) = self.started_at { && let Some(started) = self.started_at
let elapsed_secs = started.elapsed().as_secs_f64(); {
if elapsed_secs > 0.0 { let elapsed_secs = started.elapsed().as_secs_f64();
self.items_per_sec = items as f64 / elapsed_secs; if elapsed_secs > 0.0 {
} self.items_per_sec = items as f64 / elapsed_secs;
} }
} }
} }
@@ -375,8 +375,7 @@ impl SyncState {
/// Overall progress fraction (average of all lanes). /// Overall progress fraction (average of all lanes).
#[must_use] #[must_use]
pub fn overall_progress(&self) -> f64 { pub fn overall_progress(&self) -> f64 {
let active_lanes: Vec<&LaneProgress> = let active_lanes: Vec<&LaneProgress> = self.lanes.iter().filter(|l| l.total > 0).collect();
self.lanes.iter().filter(|l| l.total > 0).collect();
if active_lanes.is_empty() { if active_lanes.is_empty() {
return 0.0; return 0.0;
} }
@@ -537,10 +536,7 @@ mod tests {
} }
} }
// With ~0ms between calls, at most 0-1 additional emits expected. // With ~0ms between calls, at most 0-1 additional emits expected.
assert!( assert!(emitted <= 1, "Expected at most 1 emit, got {emitted}");
emitted <= 1,
"Expected at most 1 emit, got {emitted}"
);
} }
#[test] #[test]

View File

@@ -9,6 +9,7 @@ use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::state::command_palette::CommandPaletteState; use crate::state::command_palette::CommandPaletteState;
use crate::text_width::cursor_cell_offset;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
@@ -16,14 +17,6 @@ fn text_cell_width(text: &str) -> u16 {
text.chars().count().min(u16::MAX as usize) as u16 text.chars().count().min(u16::MAX as usize) as u16
} }
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
let mut idx = cursor.min(query.len());
while idx > 0 && !query.is_char_boundary(idx) {
idx -= 1;
}
text_cell_width(&query[..idx])
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// render_command_palette // render_command_palette
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -127,7 +127,9 @@ pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
let detail = if check.detail.len() > max_detail { let detail = if check.detail.len() > max_detail {
format!( format!(
"{}...", "{}...",
&check.detail[..check.detail.floor_char_boundary(max_detail.saturating_sub(3))] &check.detail[..check
.detail
.floor_char_boundary(max_detail.saturating_sub(3))]
) )
} else { } else {
check.detail.clone() check.detail.clone()

View File

@@ -21,8 +21,9 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw; use ftui::render::drawing::Draw;
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
use super::common::truncate_str; use super::common::truncate_str;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
use crate::text_width::cursor_cell_offset;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Colors (Flexoki palette) // Colors (Flexoki palette)
@@ -137,8 +138,7 @@ fn render_path_input(frame: &mut Frame<'_>, state: &FileHistoryState, x: u16, y:
// Cursor indicator. // Cursor indicator.
if state.path_focused { if state.path_focused {
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16; let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
let cursor_x = after_label + cursor_col;
if cursor_x < max_x { if cursor_x < max_x {
let cursor_cell = Cell { let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), // dark bg

View File

@@ -16,12 +16,12 @@ pub mod issue_detail;
pub mod issue_list; pub mod issue_list;
pub mod mr_detail; pub mod mr_detail;
pub mod mr_list; pub mod mr_list;
pub mod search;
pub mod timeline;
pub mod trace;
pub mod scope_picker; pub mod scope_picker;
pub mod search;
pub mod stats; pub mod stats;
pub mod sync; pub mod sync;
pub mod timeline;
pub mod trace;
pub mod who; pub mod who;
use ftui::layout::{Constraint, Flex}; use ftui::layout::{Constraint, Flex};
@@ -43,12 +43,12 @@ use issue_detail::render_issue_detail;
use issue_list::render_issue_list; use issue_list::render_issue_list;
use mr_detail::render_mr_detail; use mr_detail::render_mr_detail;
use mr_list::render_mr_list; use mr_list::render_mr_list;
use search::render_search;
use timeline::render_timeline;
use trace::render_trace;
use scope_picker::render_scope_picker; use scope_picker::render_scope_picker;
use search::render_search;
use stats::render_stats; use stats::render_stats;
use sync::render_sync; use sync::render_sync;
use timeline::render_timeline;
use trace::render_trace;
use who::render_who; use who::render_who;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -261,10 +261,7 @@ mod tests {
let has_content = (20..60u16).any(|x| { let has_content = (20..60u16).any(|x| {
(8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty())) (8..16u16).any(|y| frame.buffer.get(x, y).is_some_and(|cell| !cell.is_empty()))
}); });
assert!( assert!(has_content, "Expected sync idle content in center area");
has_content,
"Expected sync idle content in center area"
);
}); });
} }
} }

View File

@@ -8,8 +8,8 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::{BorderChars, Draw}; use ftui::render::drawing::{BorderChars, Draw};
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::state::scope_picker::ScopePickerState;
use crate::state::ScopeContext; use crate::state::ScopeContext;
use crate::state::scope_picker::ScopePickerState;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
@@ -131,7 +131,10 @@ pub fn render_scope_picker(
// Truncate label to fit. // Truncate label to fit.
let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix let max_label_len = content_width.saturating_sub(2) as usize; // 2 for prefix
let display = if label.len() > max_label_len { let display = if label.len() > max_label_len {
format!("{prefix}{}...", &label[..label.floor_char_boundary(max_label_len.saturating_sub(3))]) format!(
"{prefix}{}...",
&label[..label.floor_char_boundary(max_label_len.saturating_sub(3))]
)
} else { } else {
format!("{prefix}{label}") format!("{prefix}{label}")
}; };

View File

@@ -18,24 +18,11 @@
use ftui::core::geometry::Rect; use ftui::core::geometry::Rect;
use ftui::render::cell::Cell; use ftui::render::cell::Cell;
use ftui::render::drawing::Draw; use ftui::render::drawing::Draw;
/// Count display-width columns for a string (char count, not byte count).
fn text_cell_width(text: &str) -> u16 {
text.chars().count().min(u16::MAX as usize) as u16
}
/// Convert a byte-offset cursor position to a display-column offset.
fn cursor_cell_offset(query: &str, cursor: usize) -> u16 {
let mut idx = cursor.min(query.len());
while idx > 0 && !query.is_char_boundary(idx) {
idx -= 1;
}
text_cell_width(&query[..idx])
}
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::message::EntityKind; use crate::message::EntityKind;
use crate::state::search::SearchState; use crate::state::search::SearchState;
use crate::text_width::cursor_cell_offset;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};

View File

@@ -93,7 +93,15 @@ pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
if y >= area.bottom().saturating_sub(2) { if y >= area.bottom().saturating_sub(2) {
break; break;
} }
render_stat_row(frame, area.x + 2, y, label, &format_count(*count), label_width, max_x); render_stat_row(
frame,
area.x + 2,
y,
label,
&format_count(*count),
label_width,
max_x,
);
y += 1; y += 1;
} }

View File

@@ -262,8 +262,16 @@ fn render_summary(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
// Summary rows. // Summary rows.
let rows = [ let rows = [
("Issues", summary.issues.new, summary.issues.updated), ("Issues", summary.issues.new, summary.issues.updated),
("MRs", summary.merge_requests.new, summary.merge_requests.updated), (
("Discussions", summary.discussions.new, summary.discussions.updated), "MRs",
summary.merge_requests.new,
summary.merge_requests.updated,
),
(
"Discussions",
summary.discussions.new,
summary.discussions.updated,
),
("Notes", summary.notes.new, summary.notes.updated), ("Notes", summary.notes.new, summary.notes.updated),
]; ];
@@ -404,7 +412,10 @@ fn render_failed(frame: &mut Frame<'_>, area: Rect, error: &str) {
// Truncate error to fit screen. // Truncate error to fit screen.
let max_len = area.width.saturating_sub(4) as usize; let max_len = area.width.saturating_sub(4) as usize;
let display_err = if error.len() > max_len { let display_err = if error.len() > max_len {
format!("{}...", &error[..error.floor_char_boundary(max_len.saturating_sub(3))]) format!(
"{}...",
&error[..error.floor_char_boundary(max_len.saturating_sub(3))]
)
} else { } else {
error.to_string() error.to_string()
}; };
@@ -541,9 +552,7 @@ mod tests {
state.complete(3000); state.complete(3000);
state.summary = Some(SyncSummary { state.summary = Some(SyncSummary {
elapsed_ms: 3000, elapsed_ms: 3000,
project_errors: vec![ project_errors: vec![("grp/repo".into(), "timeout".into())],
("grp/repo".into(), "timeout".into()),
],
..Default::default() ..Default::default()
}); });
let area = frame.bounds(); let area = frame.bounds();

View File

@@ -135,8 +135,7 @@ fn render_path_input(frame: &mut Frame<'_>, state: &TraceState, x: u16, y: u16,
// Cursor. // Cursor.
if state.path_focused { if state.path_focused {
let cursor_col = state.path_input[..state.path_cursor].chars().count() as u16; let cursor_x = after_label + cursor_cell_offset(&state.path_input, state.path_cursor);
let cursor_x = after_label + cursor_col;
if cursor_x < max_x { if cursor_x < max_x {
let cursor_cell = Cell { let cursor_cell = Cell {
fg: PackedRgba::rgb(0x10, 0x0F, 0x0F), fg: PackedRgba::rgb(0x10, 0x0F, 0x0F),