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

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);
}
}