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:
File diff suppressed because one or more lines are too long
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +298,15 @@ 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).
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll indicator (overlaid on last visible row when results overflow).
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
#[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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
};
|
||||
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 {
|
||||
if focused {
|
||||
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_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);
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
31
src/main.rs
31
src/main.rs
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user