Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/viva/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ ratatui = "0.30.2"
semver = "1.0.28"
sha2 = "0.10.9"
unicode-normalization = "0.1.24"
unicode-width = "0.2.2"
tempfile = "3.27.0"
flate2 = "1"
tar = "0.4"
Expand Down
44 changes: 21 additions & 23 deletions crates/viva/src/tui/chrome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ pub fn inner_rect(rect: Rect) -> Rect {
}
}

/// A border plus one content cell needs three rows and three columns.
/// Smaller rects stay full-bleed: drawing the frame would leave a content
/// height of zero.
pub fn content_rect(rect: Rect) -> Rect {
if rect.width >= 3 && rect.height >= 3 {
inner_rect(rect)
} else {
rect
}
}

/// Draw the merged border grid for all pane rects. `focused` is the index
/// of the focused pane (its border cells get the accent color); titles are
/// stamped onto the named panes' top border rows.
Expand All @@ -72,8 +83,9 @@ pub fn render_pane_borders(
}
let mut cells: HashMap<(u16, u16), CellFlags> = HashMap::new();
for (index, rect) in rects.iter().enumerate() {
// Too small to hold a box: skip (the content still renders).
if rect.width < 2 || rect.height < 2 {
// Too small to hold a border plus one content cell: skip. The
// content pass draws the full rect instead of an empty frame.
if rect.width < 3 || rect.height < 3 {
continue;
}
let focused = focused == Some(index);
Expand Down Expand Up @@ -135,30 +147,16 @@ pub fn render_pane_borders(
} else {
palette.overlay1
});
let budget = rect.width.saturating_sub(4) as usize; // corners + padding
// Truncate with an ellipsis instead of cutting mid-word: the
// reported/screen labels in titles are an honesty boundary (QA P3)
// and must not silently lose their closing paren.
let full: String = format!(" {} ", title.text);
let text: String = if full.chars().count() > budget {
format!(
"{}…",
full.chars()
.take(budget.saturating_sub(1))
.collect::<String>()
)
let budget = usize::from(rect.width.saturating_sub(4)); // corners + padding
// Truncate by display width. A CJK title counted in scalars
// overruns the border and collides with the next pane.
let full = format!(" {} ", title.text);
let text = if crate::tui::text::display_width(&full) > budget {
crate::tui::text::truncate_end(&full, budget)
} else {
full
};
for (offset, ch) in text.chars().enumerate() {
let x = rect.x + 1 + offset as u16;
if x >= rect.x + rect.width - 1 {
break;
}
if let Some(cell) = buffer.cell_mut(Position::new(x, rect.y)) {
cell.set_char(ch).set_style(style);
}
}
buffer.set_stringn(rect.x + 1, rect.y, text, budget, style);
}
}

Expand Down
71 changes: 63 additions & 8 deletions crates/viva/src/tui/conversations/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,19 @@ impl ConversationTreeApp {
pub fn draw(&self, frame: &mut Frame) {
let chunks =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(frame.area());
let framed = chunks[0].width >= 3 && chunks[0].height >= 3;
let slots = if framed {
usize::from(chunks[0].height.saturating_sub(2))
} else {
usize::from(chunks[0].height)
};
let start = super::text::scroll_to_reveal(self.rows.len(), self.selected, slots);
let items: Vec<ListItem> = self
.rows
.iter()
.enumerate()
.skip(start)
.take(slots)
.map(|(i, row)| {
let marker = if self.selected == i { "▶ " } else { " " };
let indent = " ".repeat(row.depth);
Expand Down Expand Up @@ -119,14 +128,14 @@ impl ConversationTreeApp {
]))
})
.collect();
frame.render_widget(
List::new(items).block(
Block::new()
.title(" conversations — display name · harness · native ref · task ")
.borders(Borders::ALL),
),
chunks[0],
);
let block = if framed {
Block::new()
.title(" conversations — display name · harness · native ref · task ")
.borders(Borders::ALL)
} else {
Block::new()
};
frame.render_widget(List::new(items).block(block), chunks[0]);
frame.render_widget(
Paragraph::new(Line::from(self.status_line.clone()).gray()),
chunks[1],
Expand Down Expand Up @@ -212,6 +221,52 @@ mod tests {
);
}

#[test]
fn tree_scrolls_the_selected_row_into_view() {
let mut rows = vec![ConversationRow {
node_id: "root".into(),
display_name: "CNODE".into(),
depth: 0,
harness: "pi".into(),
native_ref: None,
task_id: None,
archived: false,
}];
for i in 1..40 {
rows.push(ConversationRow {
node_id: format!("n{i}"),
display_name: if i == 1 {
"CROW01".into()
} else if i == 39 {
"DEEPNAME".into()
} else {
format!("row{i:02}")
},
depth: 1,
harness: "pi".into(),
native_ref: None,
task_id: None,
archived: false,
});
}
let mut app = ConversationTreeApp::new();
app.set_rows(rows);
for _ in 0..39 {
app.on_key(KeyEvent::from(KeyCode::Char('j')));
}
let mut terminal = Terminal::new(TestBackend::new(80, 24)).expect("terminal");
terminal.draw(|frame| app.draw(frame)).expect("draw");
let view = terminal.backend().to_string();
assert!(
view.contains("DEEPNAME"),
"the selected leaf must be on screen: {view}"
);
assert!(
!view.contains("CNODE"),
"the window must leave the first page: {view}"
);
}

#[test]
fn navigation_stays_within_the_tree() {
let mut app = ConversationTreeApp::new();
Expand Down
160 changes: 61 additions & 99 deletions crates/viva/src/tui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,47 @@ pub enum Direction {
Down,
}

/// Divide `area` by `ratio` (first-child percent, clamped 10..=90).
///
/// The product is computed in `u32`. `u16` multiplication saturates at
/// 65535, so a 1374-column pane at 50% used to draw 655 columns while the
/// mouse divider, which already used `u32`, sat on column 687.
fn split_pair(area: Rect, axis: SplitAxis, ratio: u16) -> (Rect, Rect) {
let ratio = u32::from(ratio.clamp(10, 90));
match axis {
SplitAxis::Horizontal => {
let first_width = ((u32::from(area.width) * ratio) / 100) as u16;
let second_width = area.width.saturating_sub(first_width);
(
Rect {
width: first_width,
..area
},
Rect {
x: area.x.saturating_add(first_width),
width: second_width,
..area
},
)
}
SplitAxis::Vertical => {
let first_height = ((u32::from(area.height) * ratio) / 100) as u16;
let second_height = area.height.saturating_sub(first_height);
(
Rect {
height: first_height,
..area
},
Rect {
y: area.y.saturating_add(first_height),
height: second_height,
..area
},
)
}
}
}

/// First/second split ratio as a percentage for the FIRST child
/// (10..=90), so a user can never squeeze a pane to zero.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
Expand Down Expand Up @@ -200,39 +241,7 @@ impl PaneNode {
second,
ratio,
} => {
let ratio = (*ratio).clamp(10, 90);
let (first_rect, second_rect) = match axis {
SplitAxis::Horizontal => {
let first_width = area.width.saturating_mul(ratio) / 100;
let second_width = area.width.saturating_sub(first_width);
(
Rect {
width: first_width,
..area
},
Rect {
x: area.x + first_width,
width: second_width,
..area
},
)
}
SplitAxis::Vertical => {
let first_height = area.height.saturating_mul(ratio) / 100;
let second_height = area.height.saturating_sub(first_height);
(
Rect {
height: first_height,
..area
},
Rect {
y: area.y + first_height,
height: second_height,
..area
},
)
}
};
let (first_rect, second_rect) = split_pair(area, *axis, *ratio);
let mut all = first.render_layout(first_rect);
all.extend(second.render_layout(second_rect));
all
Expand All @@ -255,39 +264,7 @@ impl PaneNode {
second,
ratio,
} => {
let ratio = (*ratio).clamp(10, 90);
let (first_rect, second_rect) = match axis {
SplitAxis::Horizontal => {
let first_width = area.width.saturating_mul(ratio) / 100;
let second_width = area.width.saturating_sub(first_width);
(
Rect {
width: first_width,
..area
},
Rect {
x: area.x + first_width,
width: second_width,
..area
},
)
}
SplitAxis::Vertical => {
let first_height = area.height.saturating_mul(ratio) / 100;
let second_height = area.height.saturating_sub(first_height);
(
Rect {
height: first_height,
..area
},
Rect {
y: area.y + first_height,
height: second_height,
..area
},
)
}
};
let (first_rect, second_rect) = split_pair(area, *axis, *ratio);
let mut all = first.render_layout_chrome(first_rect);
let mut second_leaves = second.render_layout_chrome(second_rect);
// Steal the shared divider cell from the first side's edge
Expand Down Expand Up @@ -357,16 +334,10 @@ impl PaneNode {
else {
return;
};
let ratio = (*ratio).clamp(10, 90) as u32;
let (first_rect, second_rect) = split_pair(area, *axis, *ratio);
let (first_len, second_len) = match axis {
SplitAxis::Horizontal => {
let first_len = (area.width as u32 * ratio / 100) as u16;
(first_len, area.width.saturating_sub(first_len))
}
SplitAxis::Vertical => {
let first_len = (area.height as u32 * ratio / 100) as u16;
(first_len, area.height.saturating_sub(first_len))
}
SplitAxis::Horizontal => (first_rect.width, second_rect.width),
SplitAxis::Vertical => (first_rect.height, second_rect.height),
};
// A degenerate side has no divider cell between the siblings —
// nothing to grab (the chrome skips these rects too).
Expand Down Expand Up @@ -410,30 +381,6 @@ impl PaneNode {
hit_rect,
path: path.clone(),
});
let (first_rect, second_rect) = match axis {
SplitAxis::Horizontal => (
Rect {
width: first_len,
..area
},
Rect {
x: area.x + first_len,
width: second_len,
..area
},
),
SplitAxis::Vertical => (
Rect {
height: first_len,
..area
},
Rect {
y: area.y + first_len,
height: second_len,
..area
},
),
};
let mut first_path = path.clone();
first_path.push(false);
let mut second_path = path;
Expand Down Expand Up @@ -584,6 +531,21 @@ mod tests {
}
}

#[test]
fn wide_half_split_matches_the_mouse_divider() {
// 1400 columns with a 26-column sidebar leaves a 1374-column pane
// area. 50% is 687. u16 multiplication saturates at 65535 and used
// to draw 655 while the divider sat at 687.
let tree = PaneNode::split_new(PaneContent::Browser, SplitAxis::Horizontal, term("t"));
let area = Rect::new(26, 1, 1374, 40);
let drawn = tree.render_layout(area);
assert_eq!(drawn[0].1.width, 687);
assert_eq!(drawn[1].1.width, 1374 - 687);
let chrome = tree.render_layout_chrome(area);
assert_eq!(chrome[0].1.width, 687, "chrome uses the same split");
assert_eq!(tree.splits(area)[0].pos, 26 + 687 - 1);
}

#[test]
fn render_pass_assigns_disjoint_adjacent_rects() {
let tree = sample_tree();
Expand Down
Loading
Loading