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] #[must_use]
pub fn scope_filter_sql(project_id: Option<i64>, table_alias: &str) -> String { 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 { match project_id {
Some(id) => format!(" AND {table_alias}.project_id = {id}"), Some(id) => format!(" AND {table_alias}.project_id = {id}"),
None => String::new(), None => String::new(),

View File

@@ -8,6 +8,7 @@ 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::layout::classify_width;
use crate::state::doctor::{DoctorState, HealthStatus}; use crate::state::doctor::{DoctorState, HealthStatus};
use super::{TEXT, TEXT_MUTED}; use super::{TEXT, TEXT_MUTED};
@@ -83,9 +84,14 @@ pub fn render_doctor(frame: &mut Frame<'_>, state: &DoctorState, area: Rect) {
max_x, 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 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() { for (i, check) in state.checks.iter().enumerate() {
let y = rows_start_y + i as u16; 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::cell::{Cell, PackedRgba};
use ftui::render::drawing::Draw; use ftui::render::drawing::Draw;
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use super::common::truncate_str; 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::state::file_history::{FileHistoryResult, FileHistoryState};
use crate::text_width::cursor_cell_offset; 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 GREEN: PackedRgba = PackedRgba::rgb(0x87, 0x9A, 0x39); // green
const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan const CYAN: PackedRgba = PackedRgba::rgb(0x3A, 0xA9, 0x9F); // cyan
const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow const YELLOW: PackedRgba = PackedRgba::rgb(0xD0, 0xA2, 0x15); // yellow
@@ -53,6 +52,7 @@ pub fn render_file_history(
return; // Terminal too small. return; // Terminal too small.
} }
let bp = classify_width(area.width);
let x = area.x; let x = area.x;
let max_x = area.right(); let max_x = area.right();
let width = area.width; let width = area.width;
@@ -105,7 +105,7 @@ pub fn render_file_history(
} }
// --- MR list --- // --- 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 --- // --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x); 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); 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( fn render_mr_list(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
result: &FileHistoryResult, result: &FileHistoryResult,
@@ -256,10 +283,14 @@ fn render_mr_list(
start_y: u16, start_y: u16,
width: u16, width: u16,
height: usize, height: usize,
bp: Breakpoint,
) { ) {
let max_x = x + width; let max_x = x + width;
let offset = state.scroll_offset as usize; 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 for (i, mr) in result
.merge_requests .merge_requests
.iter() .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); let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title (truncated). // Title (responsive truncation).
let title = truncate_str(&mr.title, 35); let title = truncate_str(&mr.title, title_max);
let title_style = Cell { let title_style = Cell {
fg: TEXT, fg: TEXT,
bg: sel_bg, 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); 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!( let meta = format!(
"@{} {}", "@{} {}",
truncate_str(&mr.author_username, 12), truncate_str(&mr.author_username, author_max),
mr.change_type mr.change_type
); );
let meta_style = Cell { let meta_style = Cell {
@@ -339,13 +370,11 @@ fn render_mr_list(
} }
// Inline discussion snippets (rendered beneath MRs when toggled on). // 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() { if state.show_discussions && !result.discussions.is_empty() {
let disc_start_y = start_y + result.merge_requests.len().min(height) as u16; let visible_mrs = result.merge_requests.len().saturating_sub(offset).min(height);
let remaining = height.saturating_sub(result.merge_requests.len().min(height)); let disc_start_y = start_y + visible_mrs as u16;
render_discussions(frame, result, x, disc_start_y, max_x, remaining); 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, start_y: u16,
max_x: u16, max_x: u16,
max_rows: usize, max_rows: usize,
bp: Breakpoint,
) { ) {
if max_rows == 0 { if max_rows == 0 {
return; return;
} }
let disc_max = fh_disc_max(bp);
let sep_style = Cell { let sep_style = Cell {
fg: TEXT_MUTED, fg: TEXT_MUTED,
..Cell::default() ..Cell::default()
@@ -390,7 +422,7 @@ fn render_discussions(
author_style, author_style,
max_x, 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); 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 ftui::render::frame::Frame;
use crate::clock::Clock; 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::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata}; use crate::state::issue_detail::{DetailSection, IssueDetailState, IssueMetadata};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs}; use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
@@ -99,6 +102,7 @@ pub fn render_issue_detail(
return; return;
}; };
let bp = classify_width(area.width);
let max_x = area.x.saturating_add(area.width); let max_x = area.x.saturating_add(area.width);
let mut y = area.y; 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); y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row --- // --- 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 --- // --- Optional milestone / due date row (skip on Xs — too narrow) ---
if meta.milestone.is_some() || meta.due_date.is_some() { 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); 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 disc_count = state.discussions.len();
let xref_count = state.cross_refs.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 --- // --- Description section ---
if desc_h > 0 { if desc_h > 0 {
@@ -263,9 +270,12 @@ fn render_title_bar(
} }
/// Render the metadata row: `opened | alice | backend, security` /// Render the metadata row: `opened | alice | backend, security`
///
/// Responsive: Xs shows state + author only; Sm adds labels; Md+ adds assignees.
fn render_metadata_row( fn render_metadata_row(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
meta: &IssueMetadata, meta: &IssueMetadata,
bp: Breakpoint,
x: u16, x: u16,
y: u16, y: u16,
max_x: 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, " | ", muted_style, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_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); cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let labels_text = meta.labels.join(", "); let labels_text = meta.labels.join(", ");
cx = frame.print_text_clipped(cx, y, &labels_text, muted_style, max_x); 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); cx = frame.print_text_clipped(cx, y, " | ", muted_style, max_x);
let assignees_text = format!("-> {}", meta.assignees.join(", ")); let assignees_text = format!("-> {}", meta.assignees.join(", "));
let _ = frame.print_text_clipped(cx, y, &assignees_text, muted_style, max_x); 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 /// Priority: description gets min(content, 40%), discussions get most of the
/// remaining space, cross-refs get a fixed portion at the bottom. /// remaining space, cross-refs get a fixed portion at the bottom.
/// On wide terminals (`wide = true`), description gets up to 60%.
fn allocate_sections( fn allocate_sections(
available: u16, available: u16,
desc_lines: usize, desc_lines: usize,
_disc_count: usize, _disc_count: usize,
xref_count: usize, xref_count: usize,
wide: bool,
) -> (u16, u16, u16) { ) -> (u16, u16, u16) {
if available == 0 { if available == 0 {
return (0, 0, 0); return (0, 0, 0);
@@ -445,8 +459,9 @@ fn allocate_sections(
let after_xref = total.saturating_sub(xref_need); let after_xref = total.saturating_sub(xref_need);
// Description: up to 40% of remaining, but at least the content lines. // Description: up to 40% on narrow, 60% on wide terminals.
let desc_max = after_xref * 2 / 5; 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); let desc_alloc = desc_lines.min(desc_max).min(after_xref);
// Discussions: everything else. // Discussions: everything else.
@@ -584,12 +599,12 @@ mod tests {
#[test] #[test]
fn test_allocate_sections_empty() { 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] #[test]
fn test_allocate_sections_balanced() { 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!(d > 0);
assert!(disc > 0); assert!(disc > 0);
assert!(x > 0); assert!(x > 0);
@@ -598,18 +613,25 @@ mod tests {
#[test] #[test]
fn test_allocate_sections_no_xrefs() { 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!(x, 0);
assert_eq!(d + disc, 20); assert_eq!(d + disc, 20);
} }
#[test] #[test]
fn test_allocate_sections_no_discussions() { 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!(d > 0);
assert_eq!(d + disc + x, 20); 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] #[test]
fn test_count_description_lines() { fn test_count_description_lines() {
let meta = sample_metadata(); let meta = sample_metadata();
@@ -623,4 +645,27 @@ mod tests {
meta.description = String::new(); meta.description = String::new();
assert_eq!(count_description_lines(&meta, 80), 0); 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. //! changes render immediately while discussions load async.
use ftui::core::geometry::Rect; use ftui::core::geometry::Rect;
use ftui::layout::Breakpoint;
use ftui::render::cell::{Cell, PackedRgba}; 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::clock::Clock; use crate::clock::Clock;
use crate::layout::classify_width;
use crate::safety::{UrlPolicy, sanitize_for_terminal}; use crate::safety::{UrlPolicy, sanitize_for_terminal};
use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab}; use crate::state::mr_detail::{FileChangeType, MrDetailState, MrMetadata, MrTab};
use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs}; use crate::view::common::cross_ref::{CrossRefColors, render_cross_refs};
@@ -85,6 +87,7 @@ pub fn render_mr_detail(
return; return;
} }
let bp = classify_width(area.width);
let Some(ref meta) = state.metadata else { let Some(ref meta) = state.metadata else {
return; return;
}; };
@@ -96,7 +99,7 @@ pub fn render_mr_detail(
y = render_title_bar(frame, meta, area.x, y, max_x); y = render_title_bar(frame, meta, area.x, y, max_x);
// --- Metadata row --- // --- 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 --- // --- Tab bar ---
y = render_tab_bar(frame, state, area.x, y, max_x); 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`. /// 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( fn render_metadata_row(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
meta: &MrMetadata, meta: &MrMetadata,
x: u16, x: u16,
y: u16, y: u16,
max_x: u16, max_x: u16,
bp: Breakpoint,
) -> u16 { ) -> u16 {
let state_fg = match meta.state.as_str() { let state_fg = match meta.state.as_str() {
"opened" => GREEN, "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); 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, " | ", muted, max_x);
cx = frame.print_text_clipped(cx, y, &meta.author, author_style, 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);
// 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); let branch_text = format!("{} -> {}", meta.source_branch, meta.target_branch);
cx = frame.print_text_clipped(cx, y, &branch_text, muted, max_x); 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); cx = frame.print_text_clipped(cx, y, " | ", muted, max_x);
let status_fg = if meta.merge_status == "mergeable" { let status_fg = if meta.merge_status == "mergeable" {
GREEN GREEN
@@ -636,4 +647,21 @@ mod tests {
render_mr_detail(&mut frame, &state, Rect::new(0, 0, 80, 24), &clock); 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::drawing::Draw;
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::layout::{classify_width, search_show_project};
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 crate::text_width::cursor_cell_offset;
@@ -39,6 +40,8 @@ pub fn render_search(frame: &mut Frame<'_>, state: &SearchState, area: Rect) {
return; return;
} }
let bp = classify_width(area.width);
let show_project = search_show_project(bp);
let mut y = area.y; let mut y = area.y;
let max_x = area.right(); 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() { if state.results.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x); render_empty_state(frame, state, area.x + 1, y, max_x);
} else { } 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 ----------------------------------------------------- // -- Bottom hint bar -----------------------------------------------------
@@ -228,6 +231,7 @@ fn render_result_list(
start_y: u16, start_y: u16,
width: u16, width: u16,
list_height: usize, list_height: usize,
show_project: bool,
) { ) {
let max_x = x + width; let max_x = x + width;
@@ -294,13 +298,15 @@ fn render_result_list(
let after_title = let after_title =
frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x); frame.print_text_clipped(after_iid + 1, y, &result.title, label_style, max_x);
// Project path (right-aligned). // Project path (right-aligned, hidden on narrow terminals).
if show_project {
let path_width = result.project_path.len() as u16 + 2; let path_width = result.project_path.len() as u16 + 2;
let path_x = max_x.saturating_sub(path_width); let path_x = max_x.saturating_sub(path_width);
if path_x > after_title + 1 { if path_x > after_title + 1 {
frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x); frame.print_text_clipped(path_x, y, &result.project_path, detail_style, max_x);
} }
} }
}
// Scroll indicator (overlaid on last visible row when results overflow). // Scroll indicator (overlaid on last visible row when results overflow).
if state.results.len() > list_height && list_height > 0 { if state.results.len() > list_height && list_height > 0 {
@@ -476,4 +482,23 @@ mod tests {
render_search(&mut frame, &state, Rect::new(0, 0, 80, 10)); 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::drawing::Draw;
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use crate::layout::classify_width;
use crate::state::stats::StatsState; use crate::state::stats::StatsState;
use super::{ACCENT, TEXT, TEXT_MUTED}; use super::{ACCENT, TEXT, TEXT_MUTED};
@@ -63,8 +64,13 @@ pub fn render_stats(frame: &mut Frame<'_>, state: &StatsState, area: Rect) {
max_x, max_x,
); );
let bp = classify_width(area.width);
let mut y = area.y + 3; 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; let value_x = area.x + 2 + label_width;
// --- Entity Counts section --- // --- Entity Counts section ---

View File

@@ -11,6 +11,7 @@ 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::layout::{classify_width, sync_progress_bar_width};
use crate::state::sync::{SyncLane, SyncPhase, SyncState}; use crate::state::sync::{SyncLane, SyncPhase, SyncState};
use super::{ACCENT, TEXT, TEXT_MUTED}; use super::{ACCENT, TEXT, TEXT_MUTED};
@@ -109,10 +110,15 @@ fn render_running(frame: &mut Frame<'_>, state: &SyncState, area: Rect) {
} }
// Per-lane progress bars. // 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 bar_start_y = area.y + 4;
let label_width = 14u16; // "Discussions " is the longest let label_width = 14u16; // "Discussions " is the longest
let bar_x = area.x + 2 + label_width; 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() { for (i, lane) in SyncLane::ALL.iter().enumerate() {
let y = bar_start_y + i as u16; 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 ftui::render::frame::Frame;
use crate::clock::Clock; use crate::clock::Clock;
use crate::layout::{classify_width, timeline_time_width};
use crate::message::TimelineEventKind; use crate::message::TimelineEventKind;
use crate::state::timeline::TimelineState; use crate::state::timeline::TimelineState;
use crate::view::common::discussion_tree::format_relative_time; use crate::view::common::discussion_tree::format_relative_time;
@@ -121,7 +122,9 @@ pub fn render_timeline(
if state.events.is_empty() { if state.events.is_empty() {
render_empty_state(frame, state, area.x + 1, y, max_x); render_empty_state(frame, state, area.x + 1, y, max_x);
} else { } 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 -- // -- 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. /// Render the scrollable list of timeline events.
#[allow(clippy::too_many_arguments)]
fn render_event_list( fn render_event_list(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
state: &TimelineState, state: &TimelineState,
@@ -161,6 +165,7 @@ fn render_event_list(
width: u16, width: u16,
list_height: usize, list_height: usize,
clock: &dyn Clock, clock: &dyn Clock,
time_col_width: u16,
) { ) {
let max_x = x + width; let max_x = x + width;
@@ -198,10 +203,9 @@ fn render_event_list(
let mut cx = x + 1; 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_str = format_relative_time(event.timestamp_ms, clock);
let time_width = 10u16; let time_x = cx + time_col_width.saturating_sub(time_str.len() as u16);
let time_x = cx + time_width.saturating_sub(time_str.len() as u16);
let time_cell = if is_selected { let time_cell = if is_selected {
selected_cell selected_cell
} else { } else {
@@ -211,8 +215,8 @@ fn render_event_list(
..Cell::default() ..Cell::default()
} }
}; };
frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_width); frame.print_text_clipped(time_x, y, &time_str, time_cell, cx + time_col_width);
cx += time_width + 1; cx += time_col_width + 1;
// Entity prefix: #42 or !99 // Entity prefix: #42 or !99
let prefix = match event.entity_key.kind { 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::drawing::Draw;
use ftui::render::frame::Frame; use ftui::render::frame::Frame;
use ftui::layout::Breakpoint;
use crate::layout::classify_width;
use crate::state::trace::TraceState; use crate::state::trace::TraceState;
use crate::text_width::cursor_cell_offset; use crate::text_width::cursor_cell_offset;
use lore::core::trace::TraceResult; use lore::core::trace::TraceResult;
@@ -51,6 +54,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core:
return; return;
} }
let bp = classify_width(area.width);
let x = area.x; let x = area.x;
let max_x = area.right(); let max_x = area.right();
let width = area.width; let width = area.width;
@@ -103,7 +107,7 @@ pub fn render_trace(frame: &mut Frame<'_>, state: &TraceState, area: ftui::core:
} }
// --- Chain list --- // --- 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 --- // --- Hint bar ---
render_hint_bar(frame, x, hint_y, max_x); 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); 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( fn render_chain_list(
frame: &mut Frame<'_>, frame: &mut Frame<'_>,
result: &TraceResult, result: &TraceResult,
@@ -235,10 +275,16 @@ fn render_chain_list(
start_y: u16, start_y: u16,
width: u16, width: u16,
height: usize, height: usize,
bp: Breakpoint,
) { ) {
let max_x = x + width; let max_x = x + width;
let mut row = 0; 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() { for (chain_idx, chain) in result.trace_chains.iter().enumerate() {
if row >= height { if row >= height {
break; 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); let after_iid = frame.print_text_clipped(after_icon, y, &iid_str, ref_style, max_x);
// Title. // Title (responsive).
let title = truncate_str(&chain.mr_title, 30); let title = truncate_str(&chain.mr_title, title_max);
let title_style = Cell { let title_style = Cell {
fg: TEXT, fg: TEXT,
bg: sel_bg, 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); 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!( let meta = format!(
"@{} {}", "@{} {}",
truncate_str(&chain.mr_author, 12), truncate_str(&chain.mr_author, author_max),
chain.change_type chain.change_type
); );
let meta_style = Cell { let meta_style = Cell {
@@ -338,10 +384,6 @@ fn render_chain_list(
_ => TEXT_MUTED, _ => TEXT_MUTED,
}; };
let indent_style = Cell {
fg: TEXT_MUTED,
..Cell::default()
};
let after_indent = frame.print_text_clipped( let after_indent = frame.print_text_clipped(
x + 4, x + 4,
iy, iy,
@@ -361,8 +403,7 @@ fn render_chain_list(
let after_ref = let after_ref =
frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x); frame.print_text_clipped(after_indent, iy, &issue_ref, issue_ref_style, max_x);
let issue_title = truncate_str(&issue.title, 40); let issue_title = truncate_str(&issue.title, issue_title_max);
let _ = indent_style; // suppress unused
frame.print_text_clipped( frame.print_text_clipped(
after_ref, after_ref,
iy, iy,
@@ -384,7 +425,7 @@ fn render_chain_list(
} }
let dy = start_y + row as u16; 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 { let author_style = Cell {
fg: CYAN, fg: CYAN,
..Cell::default() ..Cell::default()
@@ -392,7 +433,7 @@ fn render_chain_list(
let after_author = let after_author =
frame.print_text_clipped(x + 4, dy, &author, author_style, max_x); 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 { let snippet_style = Cell {
fg: TEXT_MUTED, fg: TEXT_MUTED,
..Cell::default() ..Cell::default()

View File

@@ -23,7 +23,9 @@ use lore::core::who_types::{
ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult, ActiveResult, ExpertResult, OverlapResult, ReviewsResult, WhoResult, WorkloadResult,
}; };
use crate::layout::{classify_width, who_abbreviated_tabs};
use crate::state::who::{WhoMode, WhoState}; use crate::state::who::{WhoMode, WhoState};
use crate::text_width::cursor_cell_offset;
use super::common::truncate_str; use super::common::truncate_str;
use super::{ACCENT, BG_SURFACE, BORDER, TEXT, TEXT_MUTED}; 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(); let max_x = area.right();
// -- Mode tabs ----------------------------------------------------------- // -- 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 ----------------------------------------------------------- // -- Input bar -----------------------------------------------------------
if state.mode.needs_path() || state.mode.needs_username() { if state.mode.needs_path() || state.mode.needs_username() {
@@ -116,15 +120,21 @@ fn render_mode_tabs(
y: u16, y: u16,
_width: u16, _width: u16,
max_x: u16, max_x: u16,
abbreviated: bool,
) -> u16 { ) -> u16 {
let mut cursor_x = x; let mut cursor_x = x;
for mode in WhoMode::ALL { for mode in WhoMode::ALL {
let is_active = mode == current; let is_active = mode == current;
let label = if is_active { let name = if abbreviated {
format!("[ {} ]", mode.label()) mode.short_label()
} else { } else {
format!(" {} ", mode.label()) mode.label()
};
let label = if is_active {
format!("[ {name} ]")
} else {
format!(" {name} ")
}; };
let cell = Cell { let cell = Cell {
@@ -193,28 +203,37 @@ fn render_input_bar(
frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x); frame.print_text_clipped(after_prompt, y, display_text, text_cell, max_x);
// Cursor rendering when focused. // Cursor rendering when focused.
if focused && !text.is_empty() { if focused {
let cursor_pos = if state.mode.needs_path() {
state.path_cursor
} else {
state.username_cursor
};
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 { let cursor_cell = Cell {
fg: BG_SURFACE, fg: BG_SURFACE,
bg: TEXT, bg: TEXT,
..Cell::default() ..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_x = after_prompt + cursor_cell_offset(text, cursor_pos);
if cursor_x < max_x {
let cursor_char = text let cursor_char = text
.get(cursor_pos..) .get(cursor_pos..)
.and_then(|s| s.chars().next()) .and_then(|s| s.chars().next())
.unwrap_or(' '); .unwrap_or(' ');
frame.print_text_clipped(cursor_x, y, &cursor_char.to_string(), cursor_cell, max_x); 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::autocorrect::{self, CorrectionResult};
use lore::cli::commands::{ use lore::cli::commands::{
IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters, IngestDisplay, InitInputs, InitOptions, InitResult, ListFilters, MrListFilters,
NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, open_issue_in_browser, NoteListFilters, SearchCliFilters, SyncOptions, TimelineParams, find_lore_tui,
open_mr_in_browser, parse_trace_path, print_count, print_count_json, print_doctor_results, open_issue_in_browser, open_mr_in_browser, parse_trace_path, print_count, print_count_json,
print_drift_human, print_drift_json, print_dry_run_preview, print_dry_run_preview_json, print_doctor_results, print_drift_human, print_drift_json, print_dry_run_preview,
print_embed, print_embed_json, print_event_count, print_event_count_json, print_file_history, print_dry_run_preview_json, print_embed, print_embed_json, print_event_count,
print_file_history_json, print_generate_docs, print_generate_docs_json, print_ingest_summary, print_event_count_json, print_file_history, print_file_history_json, print_generate_docs,
print_ingest_summary_json, print_list_issues, print_list_issues_json, print_list_mrs, print_generate_docs_json, print_ingest_summary, print_ingest_summary_json, print_list_issues,
print_list_mrs_json, print_list_notes, print_list_notes_csv, print_list_notes_json, print_list_issues_json, print_list_mrs, print_list_mrs_json, print_list_notes,
print_list_notes_jsonl, print_search_results, print_search_results_json, print_show_issue, print_list_notes_csv, print_list_notes_json, print_list_notes_jsonl, print_search_results,
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json, print_search_results_json, print_show_issue, print_show_issue_json, print_show_mr,
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline, print_show_mr_json, print_stats, print_stats_json, print_sync, print_sync_json,
print_timeline_json_with_meta, print_trace, print_trace_json, print_who_human, print_who_json, print_sync_status, print_sync_status_json, print_timeline, print_timeline_json_with_meta,
query_notes, run_auth_test, run_count, run_count_events, run_doctor, run_drift, run_embed, print_trace, print_trace_json, print_who_human, print_who_json, query_notes, run_auth_test,
find_lore_tui, run_file_history, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_count, run_count_events, run_doctor, run_drift, run_embed, run_file_history,
run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues, run_list_mrs,
run_sync_status, run_timeline, run_tui, run_who, 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::render::{ColorMode, GlyphMode, Icons, LoreRenderer, Theme};
use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::robot::{RobotMeta, strip_schemas};