Files
improvise/src/ui/tile_bar.rs
Edward Langley 4686f47026 fix: scroll tile bar to keep selected tile visible
When there are more tiles than fit in the available width, the tile
bar now auto-scrolls to ensure the selected tile is always visible.
Overflow indicators (◀ ▶) show when tiles exist beyond the visible
area. Scroll offset is computed fresh each frame from tile_cat_idx.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:07:58 -07:00

154 lines
4.8 KiB
Rust

use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
widgets::Widget,
};
use unicode_width::UnicodeWidthStr;
use crate::model::Model;
use crate::ui::app::AppMode;
use crate::view::Axis;
pub struct TileBar<'a> {
pub model: &'a Model,
pub mode: &'a AppMode,
pub tile_cat_idx: usize,
}
impl<'a> TileBar<'a> {
pub fn new(model: &'a Model, mode: &'a AppMode, tile_cat_idx: usize) -> Self {
Self {
model,
mode,
tile_cat_idx,
}
}
fn axis_display(axis: Axis) -> (&'static str, Color) {
match axis {
Axis::Row => ("Row", Color::Green),
Axis::Column => ("Col", Color::Blue),
Axis::Page => ("Pag", Color::Magenta),
Axis::None => ("·", Color::DarkGray),
}
}
}
impl<'a> Widget for TileBar<'a> {
fn render(self, area: Rect, buf: &mut Buffer) {
// Clear the line to avoid stale characters from previous renders
buf.set_string(
area.x,
area.y,
" ".repeat(area.width as usize),
Style::default(),
);
let view = self.model.active_view();
let selected_cat_idx = if matches!(self.mode, AppMode::TileSelect) {
Some(self.tile_cat_idx)
} else {
None
};
let prefix = " Tiles: ";
let prefix_w = prefix.width() as u16;
buf.set_string(area.x, area.y, prefix, Style::default().fg(Color::Gray));
let cat_names: Vec<&str> = self.model.category_names();
// Compute label widths for all tiles
let labels: Vec<String> = cat_names
.iter()
.map(|cat_name| {
let (axis_symbol, _) = TileBar::axis_display(view.axis_of(cat_name));
format!(" [{cat_name} {axis_symbol}] ")
})
.collect();
let widths: Vec<u16> = labels.iter().map(|l| l.width() as u16).collect();
// Available space for tiles (after prefix)
let avail = area.width.saturating_sub(prefix_w);
// Find the minimal starting index so the selected tile is fully visible.
// We scroll by whole tiles: find the first tile to draw such that the
// selected tile fits within the available width.
let sel = selected_cat_idx.unwrap_or(0);
let mut start = 0;
loop {
// Check if selected tile is visible when starting from `start`
let mut used: u16 = 0;
let mut sel_visible = false;
for i in start..labels.len() {
if used + widths[i] > avail {
break;
}
used += widths[i];
if i == sel {
sel_visible = true;
}
}
if sel_visible || start >= sel {
break;
}
start += 1;
}
// Draw an overflow indicator if we scrolled past the beginning
let mut x = area.x + prefix_w + 1;
if start > 0 {
buf.set_string(
area.x + prefix_w,
area.y,
"",
Style::default().fg(Color::DarkGray),
);
x += 1;
}
// Render tiles from `start`
let mut last_drawn = start;
for i in start..labels.len() {
let label_w = widths[i];
if x + label_w > area.x + area.width {
break;
}
let (_, axis_color) = TileBar::axis_display(view.axis_of(cat_names[i]));
let is_selected = selected_cat_idx == Some(i);
let style = if is_selected {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(axis_color)
};
buf.set_string(x, area.y, &labels[i], style);
x += label_w;
last_drawn = i;
}
// Draw overflow indicator if tiles remain after the visible area
if last_drawn + 1 < labels.len() && x < area.x + area.width {
buf.set_string(x, area.y, "", Style::default().fg(Color::DarkGray));
x += 1;
}
// Hint
if matches!(self.mode, AppMode::TileSelect) {
let hint = " [Enter] cycle axis [r/c/p] set axis [←→] select [Esc] cancel";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
} else {
let hint = " Ctrl+↑↓←→ to move tiles";
if x + hint.width() as u16 <= area.x + area.width {
buf.set_string(x, area.y, hint, Style::default().fg(Color::DarkGray));
}
}
}
}