feat(tui): responsive breakpoints for detail views (bd-a6yb)

Apply breakpoint-aware layout to issue_detail and mr_detail views:
- Issue detail: hide labels on Xs, hide assignees on Xs/Sm, skip milestone row on Xs
- MR detail: hide branch names and merge status on Xs/Sm
- Issue detail allocate_sections gives description 60% on wide (Lg+) vs 40% narrow
- Add responsive tests for both detail views
- Close bd-a6yb: all TUI screens now adapt to terminal width

760 lib tests pass, clippy clean.
This commit is contained in:
teernisse
2026-02-18 23:59:47 -05:00
parent ae1c3e3b05
commit 026b3f0754
13 changed files with 345 additions and 104 deletions

File diff suppressed because one or more lines are too long

View File

@@ -40,6 +40,13 @@ pub struct ProjectInfo {
/// ```
#[must_use]
pub fn scope_filter_sql(project_id: Option<i64>, table_alias: &str) -> String {
debug_assert!(
!table_alias.is_empty()
&& table_alias
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_'),
"table_alias must be a valid SQL identifier, got: {table_alias:?}"
);
match project_id {
Some(id) => format!(" AND {table_alias}.project_id = {id}"),
None => String::new(),

View File

@@ -8,6 +8,7 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::doctor::{DoctorState, HealthStatus};
use super::{TEXT, TEXT_MUTED};
@@ -83,9 +84,14 @@ pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
max_x,
);
// Health check rows.
// Health check rows — name column adapts to breakpoint.
let bp = classify_width(area.width);
let rows_start_y = area.y + 4;
let name_width = 16u16;
let name_width = match bp {
ftui::layout::Breakpoint::Xs => 10u16,
ftui::layout::Breakpoint::Sm => 13,
_ => 16,
};
for (i, check) in state.checks.iter().enumerate() {
let y = rows_start_y + i as u16;

View File

@@ -17,22 +17,21 @@
//! +-----------------------------------+
//! ```
use ftui::layout::Breakpoint;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, TEXT, TEXT_MUTED};
use crate::layout::classify_width;
use crate::state::file_history::{FileHistoryResult, FileHistoryState};
use crate::text_width::cursor_cell_offset;
// ---------------------------------------------------------------------------
// Colors (Flexoki palette)
// Colors (Flexoki palette — screen-specific)
// ---------------------------------------------------------------------------
const TEXT: PackedRgba = PackedRgba::rgb(0xCE, 0xCD, 0xC3); // tx
const TEXT_MUTED: PackedRgba = PackedRgba::rgb(0x87, 0x87, 0x80); // tx-2
const BG_SURFACE: PackedRgba = PackedRgba::rgb(0x28, 0x28, 0x24); // bg-2
const ACCENT: PackedRgba = PackedRgba::rgb(0xDA, 0x70, 0x2C); // orange
const GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
@@ -53,6 +52,7 @@ pub fn render_file_history(
return; // Terminal too small.
}
let bp = classify_width(area.width);
let x = area.x;
let max_x = area.right();
let width = area.width;
@@ -105,7 +105,7 @@ pub fn render_file_history(
}
// --- MR list ---
render_mr_list(frame, result, state, x, y, width, list_height);
render_mr_list(frame, result, state, x, y, width, list_height, bp);
// --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x);
@@ -248,6 +248,33 @@ fn render_summary(frame: &mut Frame<'_>, result: &FileHistoryResult, x: u16, y:
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
/// Responsive truncation widths for file history MR rows.
const fn fh_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 25,
Breakpoint::Md => 35,
Breakpoint::Lg | Breakpoint::Xl => 55,
}
}
const fn fh_author_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
const fn fh_disc_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 25,
Breakpoint::Sm => 40,
Breakpoint::Md => 60,
Breakpoint::Lg | Breakpoint::Xl => 80,
}
}
#[allow(clippy::too_many_arguments)]
fn render_mr_list(
frame: &mut Frame<'_>,
result: &FileHistoryResult,
@@ -256,10 +283,14 @@ fn render_mr_list(
start_y: u16,
width: u16,
height: usize,
bp: Breakpoint,
) {
let max_x = x + width;
let offset = state.scroll_offset as usize;
let title_max = fh_title_max(bp);
let author_max = fh_author_max(bp);
for (i, mr) in result
.merge_requests
.iter()
@@ -315,8 +346,8 @@ fn render_mr_list(
};
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title (truncated).
let title = truncate_str(&mr.title, 35);
// Title (responsive truncation).
let title = truncate_str(&mr.title, title_max);
let title_style = Cell {
fg: TEXT,
bg: sel_bg,
@@ -324,10 +355,10 @@ fn render_mr_list(
};
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
// @author + change_type
// @author + change_type (responsive author width).
let meta = format!(
"@{} {}",
truncate_str(&mr.author_username, 12),
truncate_str(&mr.author_username, author_max),
mr.change_type
);
let meta_style = Cell {
@@ -339,13 +370,11 @@ fn render_mr_list(
}
// Inline discussion snippets (rendered beneath MRs when toggled on).
// For simplicity, discussions are shown as a separate block after the MR list
// in this initial implementation. Full inline rendering (grouped by MR) is
// a follow-up enhancement.
if state.show_discussions && !result.discussions.is_empty() {
let disc_start_y = start_y + result.merge_requests.len().min(height) as u16;
let remaining = height.saturating_sub(result.merge_requests.len().min(height));
render_discussions(frame, result, x, disc_start_y, max_x, remaining);
let visible_mrs = result.merge_requests.len().saturating_sub(offset).min(height);
let disc_start_y = start_y + visible_mrs as u16;
let remaining = height.saturating_sub(visible_mrs);
render_discussions(frame, result, x, disc_start_y, max_x, remaining, bp);
}
}
@@ -356,11 +385,14 @@ fn render_discussions(
start_y: u16,
max_x: u16,
max_rows: usize,
bp: Breakpoint,
) {
if max_rows == 0 {
return;
}
let disc_max = fh_disc_max(bp);
let sep_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
@@ -390,7 +422,7 @@ fn render_discussions(
author_style,
max_x,
);
let snippet = truncate_str(&disc.body_snippet, 60);
let snippet = truncate_str(&disc.body_snippet, disc_max);
frame.print_text_clipped(after_author, y, &snippet, disc_style, max_x);
}
}

View File

@@ -13,6 +13,9 @@ use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use ftui::layout::Breakpoint;
use crate::layout::{classify_width, detail_side_panel};
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
@@ -99,6 +102,7 @@ pub fn render_issue_detail(
return;
};
let bp = classify_width(area.width);
let max_x = area.x.saturating_add(area.width);
let mut y = area.y;
@@ -106,10 +110,12 @@ pub fn render_issue_detail(
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
y = render_metadata_row(frame, meta, bp, area.x, y, max_x);
// --- Optional milestone / due date row ---
if meta.milestone.is_some() || meta.due_date.is_some() {
// --- Optional milestone / due date row (skip on Xs — too narrow) ---
if !matches!(bp, Breakpoint::Xs)
&& (meta.milestone.is_some() || meta.due_date.is_some())
{
y = render_milestone_row(frame, meta, area.x, y, max_x);
}
@@ -129,7 +135,8 @@ pub fn render_issue_detail(
let disc_count = state.discussions.len();
let xref_count = state.cross_refs.len();
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count);
let wide = detail_side_panel(bp);
let (desc_h, disc_h, xref_h) = allocate_sections(remaining, desc_lines, disc_count, xref_count, wide);
// --- Description section ---
if desc_h > 0 {
@@ -263,9 +270,12 @@ fn render_title_bar(
}
/// Render the metadata row: `opened | alice | backend, security`
///
/// Responsive: Xs shows state + author only; Sm adds labels; Md+ adds assignees.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &IssueMetadata,
bp: Breakpoint,
x: u16,
y: u16,
max_x: u16,
@@ -292,13 +302,15 @@ fn render_metadata_row(
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
if !meta.labels.is_empty() {
// Labels: shown on Sm+
if !matches!(bp, Breakpoint::Xs) && !meta.labels.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", ");
cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x);
}
if !meta.assignees.is_empty() {
// Assignees: shown on Md+
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.assignees.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let assignees_text = format!("-> {}", meta.assignees.join(", "));
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x);
@@ -424,11 +436,13 @@ fn count_description_lines(meta: &IssueMetadata, _width: u16) -> usize {
///
/// Priority: description gets min(content, 40%), discussions get most of the
/// remaining space, cross-refs get a fixed portion at the bottom.
/// On wide terminals (`wide = true`), description gets up to 60%.
fn allocate_sections(
available: u16,
desc_lines: usize,
_disc_count: usize,
xref_count: usize,
wide: bool,
) -> (u16, u16, u16) {
if available == 0 {
return (0, 0, 0);
@@ -445,8 +459,9 @@ fn allocate_sections(
let after_xref = total.saturating_sub(xref_need);
// Description: up to 40% of remaining, but at least the content lines.
let desc_max = after_xref * 2 / 5;
// Description: up to 40% on narrow, 60% on wide terminals.
let desc_pct = if wide { 3 } else { 2 }; // numerator over 5
let desc_max = after_xref * desc_pct / 5;
let desc_alloc = desc_lines.min(desc_max).min(after_xref);
// Discussions: everything else.
@@ -584,12 +599,12 @@ mod tests {
#[test]
fn test_allocate_sections_empty() {
assert_eq!(allocate_sections(0, 5, 3, 2), (0, 0, 0));
assert_eq!(allocate_sections(0, 5, 3, 2, false), (0, 0, 0));
}
#[test]
fn test_allocate_sections_balanced() {
let (d, disc, x) = allocate_sections(20, 5, 3, 2);
let (d, disc, x) = allocate_sections(20, 5, 3, 2, false);
assert!(d > 0);
assert!(disc > 0);
assert!(x > 0);
@@ -598,18 +613,25 @@ mod tests {
#[test]
fn test_allocate_sections_no_xrefs() {
let (d, disc, x) = allocate_sections(20, 5, 3, 0);
let (d, disc, x) = allocate_sections(20, 5, 3, 0, false);
assert_eq!(x, 0);
assert_eq!(d + disc, 20);
}
#[test]
fn test_allocate_sections_no_discussions() {
let (d, disc, x) = allocate_sections(20, 5, 0, 2);
let (d, disc, x) = allocate_sections(20, 5, 0, 2, false);
assert!(d > 0);
assert_eq!(d + disc + x, 20);
}
#[test]
fn test_allocate_sections_wide_gives_more_description() {
let (d_narrow, _, _) = allocate_sections(20, 10, 3, 2, false);
let (d_wide, _, _) = allocate_sections(20, 10, 3, 2, true);
assert!(d_wide >= d_narrow, "wide should give desc at least as much space");
}
#[test]
fn test_count_description_lines() {
let meta = sample_metadata();
@@ -623,4 +645,27 @@ mod tests {
meta.description = String::new();
assert_eq!(count_description_lines(&meta, 80), 0);
}
#[test]
fn test_render_issue_detail_responsive_breakpoints() {
let clock = FakeClock::from_ms(1_700_000_060_000);
// Narrow (Xs=50): milestone row hidden, labels/assignees hidden.
with_frame!(50, 24, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock);
});
// Medium (Sm=70): milestone shown, labels shown, assignees hidden.
with_frame!(70, 24, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 70, 24), &clock);
});
// Wide (Lg=130): all metadata, description gets more space.
with_frame!(130, 40, |frame| {
let state = sample_state_with_metadata();
render_issue_detail(&mut frame, &state, Rect::new(0, 0, 130, 40), &clock);
});
}
}

View File

@@ -7,11 +7,13 @@
//! changes render immediately while discussions load async.
use ftui::core::geometry::Rect;
use ftui::layout::Breakpoint;
use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::layout::classify_width;
use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
@@ -85,6 +87,7 @@ pub fn render_mr_detail(
return;
}
let bp = classify_width(area.width);
let Some(ref meta) = state.metadata else {
return;
};
@@ -96,7 +99,7 @@ pub fn render_mr_detail(
y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row ---
y = render_metadata_row(frame, meta, area.x, y, max_x);
y = render_metadata_row(frame, meta, area.x, y, max_x, bp);
// --- Tab bar ---
y = render_tab_bar(frame, state, area.x, y, max_x);
@@ -150,12 +153,16 @@ fn render_title_bar(frame: &mut Frame<'_>, meta: &MrMetadata, x: u16, y: u16, ma
}
/// Render `opened | alice | fix-auth -> main | mergeable`.
///
/// On narrow terminals (Xs/Sm), branch names and merge status are hidden
/// to avoid truncating more critical information.
fn render_metadata_row(
frame: &mut Frame<'_>,
meta: &MrMetadata,
x: u16,
y: u16,
max_x: u16,
bp: Breakpoint,
) -> u16 {
let state_fg = match meta.state.as_str() {
"opened" => GREEN,
@@ -179,12 +186,16 @@ fn render_metadata_row(
let mut cx = frame.print_text_clipped(x, y, &meta.state, state_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, max_x);
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
// Branch names: hidden on Xs/Sm to save horizontal space.
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x);
}
if !meta.merge_status.is_empty() {
// Merge status: hidden on Xs/Sm.
if !matches!(bp, Breakpoint::Xs | Breakpoint::Sm) && !meta.merge_status.is_empty() {
cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let status_fg = if meta.merge_status == "mergeable" {
GREEN
@@ -636,4 +647,21 @@ mod tests {
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock);
});
}
#[test]
fn test_render_mr_detail_responsive_breakpoints() {
let clock = FakeClock::from_ms(1_700_000_060_000);
// Narrow (Xs=50): branches and merge status hidden.
with_frame!(50, 24, |frame| {
let state = sample_mr_state();
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 50, 24), &clock);
});
// Medium (Md=100): all metadata shown.
with_frame!(100, 24, |frame| {
let state = sample_mr_state();
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 100, 24), &clock);
});
}
}

View File

@@ -20,6 +20,7 @@ use ftui::render::cell::Cell;
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::{classify_width, search_show_project};
use crate::message::EntityKind;
use crate::state::search::SearchState;
use crate::text_width::cursor_cell_offset;
@@ -39,6 +40,8 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
return;
}
let bp = classify_width(area.width);
let show_project = search_show_project(bp);
let mut y = area.y;
let max_x = area.right();
@@ -99,7 +102,7 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_result_list(frame, state, area.x, y, area.width, list_height);
render_result_list(frame, state, area.x, y, area.width, list_height, show_project);
}
// -- Bottom hint bar -----------------------------------------------------
@@ -228,6 +231,7 @@ fn render_result_list(
start_y: u16,
width: u16,
list_height: usize,
show_project: bool,
) {
let max_x = x + width;
@@ -294,11 +298,13 @@ fn render_result_list(
let after_title =
frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x);
// Project path (right-aligned).
let path_width = result.project_path.len() as u16 + 2;
let path_x = max_x.saturating_sub(path_width);
if path_x > after_title + 1 {
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
// Project path (right-aligned, hidden on narrow terminals).
if show_project {
let path_width = result.project_path.len() as u16 + 2;
let path_x = max_x.saturating_sub(path_width);
if path_x > after_title + 1 {
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
}
}
}
@@ -476,4 +482,23 @@ mod tests {
render_search(&mut frame, &state, Rect::new(0, 0, 80, 10));
});
}
#[test]
fn test_render_search_responsive_breakpoints() {
// Narrow (Xs=50): project path hidden.
with_frame!(50, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 50, 24));
});
// Standard (Md=100): project path shown.
with_frame!(100, 24, |frame| {
let mut state = SearchState::default();
state.enter(fts_caps());
state.results = sample_results(3);
render_search(&mut frame, &state, Rect::new(0, 0, 100, 24));
});
}
}

View File

@@ -8,6 +8,7 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::stats::StatsState;
use super::{ACCENT, TEXT, TEXT_MUTED};
@@ -63,8 +64,13 @@ pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
max_x,
);
let bp = classify_width(area.width);
let mut y = area.y + 3;
let label_width = 22u16;
let label_width = match bp {
ftui::layout::Breakpoint::Xs => 16u16,
ftui::layout::Breakpoint::Sm => 18,
_ => 22,
};
let value_x = area.x + 2 + label_width;
// --- Entity Counts section ---

View File

@@ -11,6 +11,7 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::layout::{classify_width, sync_progress_bar_width};
use crate::state::sync::{SyncLane, SyncPhase, SyncState};
use super::{ACCENT, TEXT, TEXT_MUTED};
@@ -109,10 +110,15 @@ fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
}
// Per-lane progress bars.
let bp = classify_width(area.width);
let max_bar = sync_progress_bar_width(bp);
let bar_start_y = area.y + 4;
let label_width = 14u16; // "Discussions " is the longest
let bar_x = area.x + 2 + label_width;
let bar_width = area.width.saturating_sub(4 + label_width + 12); // 12 for count text
let bar_width = area
.width
.saturating_sub(4 + label_width + 12)
.min(max_bar); // Cap bar width for very wide terminals
for (i, lane) in SyncLane::ALL.iter().enumerate() {
let y = bar_start_y + i as u16;

View File

@@ -20,6 +20,7 @@ use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use crate::clock::Clock;
use crate::layout::{classify_width, timeline_time_width};
use crate::message::TimelineEventKind;
use crate::state::timeline::TimelineState;
use crate::view::common::discussion_tree::format_relative_time;
@@ -121,7 +122,9 @@ pub fn render_timeline(
if state.events.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x);
} else {
render_event_list(frame, state, area.x, y, area.width, list_height, clock);
let bp = classify_width(area.width);
let time_col_width = timeline_time_width(bp);
render_event_list(frame, state, area.x, y, area.width, list_height, clock, time_col_width);
}
// -- Hint bar --
@@ -153,6 +156,7 @@ fn render_empty_state(frame: &mut Frame<'_>, state: &TimelineState, x: u16, y: u
// ---------------------------------------------------------------------------
/// Render the scrollable list of timeline events.
#[allow(clippy::too_many_arguments)]
fn render_event_list(
frame: &mut Frame<'_>,
state: &TimelineState,
@@ -161,6 +165,7 @@ fn render_event_list(
width: u16,
list_height: usize,
clock: &dyn Clock,
time_col_width: u16,
) {
let max_x = x + width;
@@ -198,10 +203,9 @@ fn render_event_list(
let mut cx = x + 1;
// Timestamp gutter (right-aligned in ~10 chars).
// Timestamp gutter (right-aligned, width varies by breakpoint).
let time_str = format_relative_time(event.timestamp_ms, clock);
let time_width = 10u16;
let time_x = cx + time_width.saturating_sub(time_str.len() as u16);
let time_x = cx + time_col_width.saturating_sub(time_str.len() as u16);
let time_cell = if is_selected {
selected_cell
} else {
@@ -211,8 +215,8 @@ fn render_event_list(
..Cell::default()
}
};
frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_width);
cx += time_width + 1;
frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_col_width);
cx += time_col_width + 1;
// Entity prefix: #42 or !99
let prefix = match event.entity_key.kind {

View File

@@ -23,6 +23,9 @@ use ftui::render::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw;
use ftui::render::frame::Frame;
use ftui::layout::Breakpoint;
use crate::layout::classify_width;
use crate::state::trace::TraceState;
use crate::text_width::cursor_cell_offset;
use lore::core::trace::TraceResult;
@@ -51,6 +54,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core:
return;
}
let bp = classify_width(area.width);
let x = area.x;
let max_x = area.right();
let width = area.width;
@@ -103,7 +107,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core:
}
// --- Chain list ---
render_chain_list(frame, result, state, x, y, width, list_height);
render_chain_list(frame, result, state, x, y, width, list_height, bp);
// --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x);
@@ -227,6 +231,42 @@ fn render_summary(frame: &mut Frame<'_>, result: &TraceResult, x: u16, y: u16, m
frame.print_text_clipped(x + 1, y, &summary, style, max_x);
}
/// Responsive truncation widths for trace chain rows.
const fn chain_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 15,
Breakpoint::Sm => 22,
Breakpoint::Md => 30,
Breakpoint::Lg | Breakpoint::Xl => 50,
}
}
const fn chain_author_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs | Breakpoint::Sm => 8,
Breakpoint::Md | Breakpoint::Lg | Breakpoint::Xl => 12,
}
}
const fn expanded_issue_title_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 20,
Breakpoint::Sm => 30,
Breakpoint::Md => 40,
Breakpoint::Lg | Breakpoint::Xl => 60,
}
}
const fn expanded_disc_snippet_max(bp: Breakpoint) -> usize {
match bp {
Breakpoint::Xs => 25,
Breakpoint::Sm => 40,
Breakpoint::Md => 60,
Breakpoint::Lg | Breakpoint::Xl => 80,
}
}
#[allow(clippy::too_many_arguments)]
fn render_chain_list(
frame: &mut Frame<'_>,
result: &TraceResult,
@@ -235,10 +275,16 @@ fn render_chain_list(
start_y: u16,
width: u16,
height: usize,
bp: Breakpoint,
) {
let max_x = x + width;
let mut row = 0;
let title_max = chain_title_max(bp);
let author_max = chain_author_max(bp);
let issue_title_max = expanded_issue_title_max(bp);
let disc_max = expanded_disc_snippet_max(bp);
for (chain_idx, chain) in result.trace_chains.iter().enumerate() {
if row >= height {
break;
@@ -294,8 +340,8 @@ fn render_chain_list(
};
let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title.
let title = truncate_str(&chain.mr_title, 30);
// Title (responsive).
let title = truncate_str(&chain.mr_title, title_max);
let title_style = Cell {
fg: TEXT,
bg: sel_bg,
@@ -303,10 +349,10 @@ fn render_chain_list(
};
let after_title = frame.print_text_clipped(after_iid + 1, y, &title, title_style, max_x);
// @author + change_type
// @author + change_type (responsive author width).
let meta = format!(
"@{} {}",
truncate_str(&chain.mr_author, 12),
truncate_str(&chain.mr_author, author_max),
chain.change_type
);
let meta_style = Cell {
@@ -338,10 +384,6 @@ fn render_chain_list(
_ => TEXT_MUTED,
};
let indent_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_indent = frame.print_text_clipped(
x + 4,
iy,
@@ -361,8 +403,7 @@ fn render_chain_list(
let after_ref =
frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x);
let issue_title = truncate_str(&issue.title, 40);
let _ = indent_style; // suppress unused
let issue_title = truncate_str(&issue.title, issue_title_max);
frame.print_text_clipped(
after_ref,
iy,
@@ -384,7 +425,7 @@ fn render_chain_list(
}
let dy = start_y + row as u16;
let author = format!("@{}: ", truncate_str(&disc.author_username, 12));
let author = format!("@{}: ", truncate_str(&disc.author_username, author_max));
let author_style = Cell {
fg: CYAN,
..Cell::default()
@@ -392,7 +433,7 @@ fn render_chain_list(
let after_author =
frame.print_text_clipped(x + 4, dy, &author, author_style, max_x);
let snippet = truncate_str(&disc.body, 60);
let snippet = truncate_str(&disc.body, disc_max);
let snippet_style = Cell {
fg: TEXT_MUTED,
..Cell::default()

View File

@@ -23,7 +23,9 @@ use lore::core::who_types::{
ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult,
};
use crate::layout::{classify_width, who_abbreviated_tabs};
use crate::state::who::{WhoMode, WhoState};
use crate::text_width::cursor_cell_offset;
use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED};
@@ -51,7 +53,9 @@ pub fn render_who(frame: &mut Frame<'_>, state: &WhoState, area: Rect) {
let max_x = area.right();
// -- Mode tabs -----------------------------------------------------------
y = render_mode_tabs(frame, state.mode, area.x, y, area.width, max_x);
let bp = classify_width(area.width);
let abbreviated = who_abbreviated_tabs(bp);
y = render_mode_tabs(frame, state.mode, area.x, y, area.width, max_x, abbreviated);
// -- Input bar -----------------------------------------------------------
if state.mode.needs_path() || state.mode.needs_username() {
@@ -116,15 +120,21 @@ fn render_mode_tabs(
y: u16,
_width: u16,
max_x: u16,
abbreviated: bool,
) -> u16 {
let mut cursor_x = x;
for mode in WhoMode::ALL {
let is_active = mode == current;
let label = if is_active {
format!("[ {} ]", mode.label())
let name = if abbreviated {
mode.short_label()
} else {
format!(" {} ", mode.label())
mode.label()
};
let label = if is_active {
format!("[ {name} ]")
} else {
format!(" {name} ")
};
let cell = Cell {
@@ -193,28 +203,37 @@ fn render_input_bar(
frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x);
// Cursor rendering when focused.
if focused && !text.is_empty() {
let cursor_pos = if state.mode.needs_path() {
state.path_cursor
} else {
state.username_cursor
if focused {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
};
let cursor_col = text[..cursor_pos.min(text.len())]
.chars()
.count()
.min(u16::MAX as usize) as u16;
let cursor_x = after_prompt + cursor_col;
if cursor_x < max_x {
let cursor_cell = Cell {
fg: BG_SURFACE,
bg: TEXT,
..Cell::default()
if text.is_empty() {
// Show cursor at input start when empty.
if after_prompt < max_x {
frame.print_text_clipped(after_prompt, y, " ", cursor_cell, max_x);
}
} else {
let cursor_pos = if state.mode.needs_path() {
state.path_cursor
} else {
state.username_cursor
};
let cursor_char = text
.get(cursor_pos..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x);
let cursor_x = after_prompt + cursor_cell_offset(text, cursor_pos);
if cursor_x < max_x {
let cursor_char = text
.get(cursor_pos..)
.and_then(|s| s.chars().next())
.unwrap_or(' ');
frame.print_text_clipped(
cursor_x,
y,
&cursor_char.to_string(),
cursor_cell,
max_x,
);
}
}
}
@@ -1033,4 +1052,25 @@ mod tests {
});
}
}
#[test]
fn test_render_who_responsive_breakpoints() {
// Narrow (Xs=50): abbreviated tabs should fit.
with_frame!(50, 24, |frame| {
let state = WhoState::default();
render_who(&mut frame, &state, Rect::new(0, 0, 50, 24));
});
// Medium (Md=90): full tab labels.
with_frame!(90, 24, |frame| {
let state = WhoState::default();
render_who(&mut frame, &state, Rect::new(0, 0, 90, 24));
});
// Wide (Lg=130): full tab labels, more room.
with_frame!(130, 24, |frame| {
let state = WhoState::default();
render_who(&mut frame, &state, Rect::new(0, 0, 130, 24));
});
}
}

View File

@@ -10,21 +10,22 @@ use lore::Config;
use lore::cli::autocorrect::{self, CorrectionResult};
use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser,
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results,
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json,
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history,
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary,
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs,
print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json,
print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json,
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed,
find_lore_tui, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init,
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync,
run_sync_status, run_timeline, run_tui, run_who,
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, find_lore_tui,
open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json,
print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview,
print_dry_run_preview_json, print_embed, print_embed_json, print_event_count,
print_event_count_json, print_file_history, print_file_history_json, print_generate_docs,
print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes,
print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_search_results,
print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
run_count, run_count_events, run_doctor, run_drift, run_embed, run_file_history,
run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline,
run_tui, run_who,
};
use lore::cli::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas};