use std::collections::{BTreeMap, BTreeSet};
use bcode_settings::{SetupSectionId, SetupSectionStatus};
use bmux_keyboard::KeyCode;
use bmux_tui::component::{Component, Constraints, LayoutCx};
use bmux_tui::event::{Event, MouseButton, MouseEvent, MouseEventKind};
use bmux_tui::geometry::{Point, Rect};
use bmux_tui::paint::PaintCx;
use bmux_tui::prelude::{Line, Span, Style};
use bmux_tui_components::scroll_view::{ScrollViewOutcome, ScrollViewState};
use bmux_tui_components::text_view::{TextViewComponent, TextViewPolicy};
use std::cell::Cell;
use super::theme::PresentedTheme;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BoardSpot {
pub id: SetupSectionId,
pub title: String,
pub subtitle: String,
pub status: SetupSectionStatus,
pub hint: BoardSpotLayoutHint,
}
impl BoardSpot {
#[must_use]
pub fn new(
id: SetupSectionId,
title: impl Into<String>,
subtitle: impl Into<String>,
status: SetupSectionStatus,
) -> Self {
Self {
id,
title: title.into(),
subtitle: subtitle.into(),
status,
hint: BoardSpotLayoutHint::default(),
}
}
#[must_use]
pub const fn layer(mut self, layer: u16) -> Self {
self.hint.preferred_layer = Some(layer);
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BoardSpotLayoutHint {
pub preferred_layer: Option<u16>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BoardConnection {
pub from: SetupSectionId,
pub to: SetupSectionId,
}
impl BoardConnection {
#[must_use]
pub const fn new(from: SetupSectionId, to: SetupSectionId) -> Self {
Self { from, to }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SetupBoardPolicy {
pub spot_width: u16,
pub spot_height: u16,
pub horizontal_gap: u16,
pub vertical_gap: u16,
pub padding: u16,
}
impl SetupBoardPolicy {
#[must_use]
pub const fn new() -> Self {
Self {
spot_width: 18,
spot_height: 4,
horizontal_gap: 6,
vertical_gap: 3,
padding: 2,
}
}
}
impl Default for SetupBoardPolicy {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetupBoardState {
pub scroll: ScrollViewState,
pub focused: SetupSectionId,
pub hovered: Option<SetupSectionId>,
pub pressed: Option<SetupSectionId>,
drag_origin: Option<(Point, u64, u64)>,
}
impl SetupBoardState {
#[must_use]
pub const fn new(focused: SetupSectionId) -> Self {
Self {
scroll: ScrollViewState::new(),
focused,
hovered: None,
pressed: None,
drag_origin: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SetupBoardOutcome {
Ignored,
Redraw,
Panned,
Focused(SetupSectionId),
Selected(SetupSectionId),
}
impl SetupBoardOutcome {
#[must_use]
pub const fn is_handled(self) -> bool {
!matches!(self, Self::Ignored)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SetupBoard<'a> {
spots: &'a [BoardSpot],
connections: &'a [BoardConnection],
policy: SetupBoardPolicy,
theme: Option<&'a PresentedTheme>,
}
impl<'a> SetupBoard<'a> {
#[must_use]
pub const fn new(spots: &'a [BoardSpot], connections: &'a [BoardConnection]) -> Self {
Self {
spots,
connections,
policy: SetupBoardPolicy::new(),
theme: None,
}
}
#[must_use]
pub const fn theme(mut self, theme: &'a PresentedTheme) -> Self {
self.theme = Some(theme);
self
}
pub fn render(self, area: Rect, state: &SetupBoardState, frame: &mut PaintCx<'_, '_>) {
let lines = self.render_lines(state);
let scroll = Cell::new(state.scroll);
let component =
TextViewComponent::new("setup.board", &lines, &scroll).policy(board_scroll_policy());
let layout = component.layout(Constraints::tight(area.size()), &mut LayoutCx::new());
frame.with_child(
i32::from(area.x),
i64::from(area.y),
bmux_tui::paint::LocalRect::new(0, 0, area.width, area.height),
|cx| component.paint(&layout, cx),
);
}
#[cfg(test)]
pub fn handle_event(
self,
area: Rect,
state: &mut SetupBoardState,
event: &Event,
) -> SetupBoardOutcome {
self.handle_event_with_keymap(
area,
state,
event,
&super::keymap::BmuxKeyMap::from_config(&bcode_config::TuiConfig::default()),
)
}
pub fn handle_event_with_keymap(
self,
area: Rect,
state: &mut SetupBoardState,
event: &Event,
keymap: &super::keymap::BmuxKeyMap,
) -> SetupBoardOutcome {
use bcode_plugin_sdk::tui::keymap::controls::{ControlAction, ControlInput};
let mapped;
let event = if let Event::Key(stroke) = event {
let supported = [
ControlAction::Up,
ControlAction::Down,
ControlAction::Left,
ControlAction::Right,
ControlAction::Submit,
ControlAction::Toggle,
ControlAction::FocusNext,
ControlAction::FocusPrevious,
ControlAction::PageUp,
ControlAction::PageDown,
ControlAction::Home,
ControlAction::End,
];
let Some(native) =
ControlInput::from_registry(keymap.registry(), "setup", *stroke, &supported, false)
.native_stroke()
else {
return SetupBoardOutcome::Ignored;
};
mapped = Event::Key(native);
&mapped
} else {
event
};
let lines = self.render_lines(state);
let scroll = Cell::new(state.scroll);
let scroll_area =
TextViewComponent::new("setup.board", &lines, &scroll).policy(board_scroll_policy());
let layout = scroll_area.layout(Constraints::tight(area.size()), &mut LayoutCx::new());
let scroll_outcome = match event {
Event::Mouse(mouse)
if matches!(
mouse.kind,
MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left)
) =>
{
scroll_area.handle_event(area, &layout, event)
}
_ => ScrollViewOutcome::Ignored,
};
if let Event::Mouse(mouse) = event
&& let Some(outcome) = self.handle_mouse(area, state, *mouse)
{
return outcome;
}
if let Event::Key(key) = event
&& let Some(outcome) = if key.modifiers.shift && key.key == KeyCode::Tab {
self.focus_relative(state, FocusStep::Previous)
} else {
self.handle_key(state, key.key)
}
{
return outcome;
}
let scroll_outcome = if matches!(scroll_outcome, ScrollViewOutcome::Ignored) {
scroll_area.handle_event(area, &layout, event)
} else {
scroll_outcome
};
state.scroll = scroll.get();
match scroll_outcome {
ScrollViewOutcome::Ignored => SetupBoardOutcome::Ignored,
ScrollViewOutcome::Scrolled { .. } | ScrollViewOutcome::HorizontalScrolled { .. } => {
SetupBoardOutcome::Panned
}
}
}
#[must_use]
pub fn render_lines(self, state: &SetupBoardState) -> Vec<Line> {
let layout = self.layout();
let mut surface = BoardSurface::new(layout.width, layout.height);
for path in &layout.paths {
path.render(&mut surface, path_style(self.theme));
}
for spot in &layout.spots {
BoardSpotComponent::new(spot).render(&mut surface, state, self.theme);
}
surface.into_lines()
}
fn handle_key(self, state: &mut SetupBoardState, key: KeyCode) -> Option<SetupBoardOutcome> {
match key {
KeyCode::Enter | KeyCode::Space | KeyCode::Char(' ') => {
Some(SetupBoardOutcome::Selected(state.focused))
}
KeyCode::Tab => self.focus_relative(state, FocusStep::Next),
KeyCode::Left => self.focus_direction(state, Direction::Left),
KeyCode::Right => self.focus_direction(state, Direction::Right),
KeyCode::Up => self.focus_direction(state, Direction::Up),
KeyCode::Down => self.focus_direction(state, Direction::Down),
KeyCode::Char(_)
| KeyCode::Backspace
| KeyCode::Delete
| KeyCode::Escape
| KeyCode::Home
| KeyCode::End
| KeyCode::PageUp
| KeyCode::PageDown
| KeyCode::Insert
| KeyCode::F(_) => None,
}
}
fn focus_relative(
self,
state: &mut SetupBoardState,
step: FocusStep,
) -> Option<SetupBoardOutcome> {
let position = self
.spots
.iter()
.position(|spot| spot.id == state.focused)
.unwrap_or_default();
let next = match step {
FocusStep::Next => (position + 1).min(self.spots.len().saturating_sub(1)),
FocusStep::Previous => position.saturating_sub(1),
};
let id = self.spots.get(next)?.id;
state.focused = id;
Some(SetupBoardOutcome::Focused(id))
}
fn focus_direction(
self,
state: &mut SetupBoardState,
direction: Direction,
) -> Option<SetupBoardOutcome> {
let layout = self.layout();
let current = layout
.spots
.iter()
.find(|spot| spot.model.id == state.focused)?;
let current_center = rect_center(current.rect);
let next = layout
.spots
.iter()
.filter(|spot| spot.model.id != state.focused)
.filter(|spot| direction.matches(current_center, rect_center(spot.rect)))
.min_by_key(|spot| direction.distance(current_center, rect_center(spot.rect)))?;
state.focused = next.model.id;
Some(SetupBoardOutcome::Focused(next.model.id))
}
fn handle_mouse(
self,
area: Rect,
state: &mut SetupBoardState,
mouse: MouseEvent,
) -> Option<SetupBoardOutcome> {
if !area.contains(mouse.position) {
return None;
}
let relative_point = Point::new(
mouse.position.x.saturating_sub(area.x),
mouse.position.y.saturating_sub(area.y),
);
let virtual_point = Point::new(
relative_point.x.saturating_add(
u16::try_from(state.scroll.horizontal_offset()).unwrap_or(u16::MAX),
),
relative_point
.y
.saturating_add(u16::try_from(state.scroll.vertical_offset()).unwrap_or(u16::MAX)),
);
let layout = self.layout();
let hit = layout.spot_at(virtual_point).map(|spot| spot.model.id);
match mouse.kind {
MouseEventKind::Move => {
if state.hovered != hit {
state.hovered = hit;
return Some(SetupBoardOutcome::Redraw);
}
Some(SetupBoardOutcome::Ignored)
}
MouseEventKind::Down(MouseButton::Left) => {
state.drag_origin = Some((
mouse.position,
state.scroll.horizontal_offset(),
state.scroll.vertical_offset(),
));
state.pressed = hit;
hit.map(|_| SetupBoardOutcome::Redraw)
}
MouseEventKind::Up(MouseButton::Left) => {
state.drag_origin = None;
let pressed = state.pressed.take();
if let (Some(pressed), Some(hit)) = (pressed, hit)
&& pressed == hit
{
return Some(SetupBoardOutcome::Selected(hit));
}
Some(SetupBoardOutcome::Redraw)
}
MouseEventKind::Drag(MouseButton::Left) if state.drag_origin.is_some() => {
let (origin, horizontal, vertical) = state.drag_origin.expect("drag origin");
state.pressed = None;
let shift = |offset: u64, from: u16, to: u16| {
offset
.saturating_add(u64::from(from.saturating_sub(to)))
.saturating_sub(u64::from(to.saturating_sub(from)))
};
let layout = self.layout();
state.scroll.set_horizontal_offset(
shift(horizontal, origin.x, mouse.position.x)
.min(u64::from(layout.width.saturating_sub(area.width))),
);
state.scroll.set_vertical_offset(
shift(vertical, origin.y, mouse.position.y)
.min(u64::from(layout.height.saturating_sub(area.height))),
);
Some(SetupBoardOutcome::Panned)
}
MouseEventKind::Down(_)
| MouseEventKind::Up(_)
| MouseEventKind::Drag(_)
| MouseEventKind::ScrollUp
| MouseEventKind::ScrollDown
| MouseEventKind::ScrollLeft
| MouseEventKind::ScrollRight => None,
}
}
fn layout(self) -> BoardLayout<'a> {
BoardLayoutEngine::new(self.policy).layout(self.spots, self.connections)
}
}
#[derive(Debug, Clone)]
struct BoardLayout<'a> {
width: u16,
height: u16,
spots: Vec<LaidOutBoardSpot<'a>>,
paths: Vec<LaidOutBoardPath>,
}
impl BoardLayout<'_> {
fn spot_at(&self, point: Point) -> Option<&LaidOutBoardSpot<'_>> {
self.spots.iter().find(|spot| spot.rect.contains(point))
}
}
#[derive(Debug, Clone)]
struct LaidOutBoardSpot<'a> {
model: &'a BoardSpot,
rect: Rect,
}
#[derive(Debug, Clone, Copy)]
struct LaidOutBoardPath {
from: Rect,
to: Rect,
}
impl LaidOutBoardPath {
fn render(self, surface: &mut BoardSurface, style: Style) {
let from = Point::new(
self.from.x + self.from.width / 2,
self.from.y + self.from.height - 1,
);
let to = Point::new(self.to.x + self.to.width / 2, self.to.y);
let mid_y = from.y.saturating_add(to.y.saturating_sub(from.y) / 2);
surface.draw_vertical(from.x, from.y.saturating_add(1), mid_y, "│", style);
surface.draw_horizontal(from.x.min(to.x), from.x.max(to.x), mid_y, "─", style);
surface.draw_vertical(to.x, mid_y, to.y.saturating_sub(1), "│", style);
surface.put(to.x, to.y.saturating_sub(1), "▼", style);
if from.x != to.x {
surface.put(from.x, mid_y, if to.x > from.x { "╰" } else { "╯" }, style);
surface.put(to.x, mid_y, if to.x > from.x { "╮" } else { "╭" }, style);
}
}
}
#[derive(Debug, Clone, Copy)]
enum FocusStep {
Next,
Previous,
}
#[derive(Debug, Clone, Copy)]
enum Direction {
Left,
Right,
Up,
Down,
}
impl Direction {
const fn matches(self, from: Point, to: Point) -> bool {
match self {
Self::Left => to.x < from.x,
Self::Right => to.x > from.x,
Self::Up => to.y < from.y,
Self::Down => to.y > from.y,
}
}
fn distance(self, from: Point, to: Point) -> u32 {
let major = match self {
Self::Left => u32::from(from.x.saturating_sub(to.x)),
Self::Right => u32::from(to.x.saturating_sub(from.x)),
Self::Up => u32::from(from.y.saturating_sub(to.y)),
Self::Down => u32::from(to.y.saturating_sub(from.y)),
};
let minor = match self {
Self::Left | Self::Right => u32::from(from.y.abs_diff(to.y)),
Self::Up | Self::Down => u32::from(from.x.abs_diff(to.x)),
};
major.saturating_mul(10).saturating_add(minor)
}
}
const fn rect_center(rect: Rect) -> Point {
Point::new(rect.x + rect.width / 2, rect.y + rect.height / 2)
}
struct BoardLayoutEngine {
policy: SetupBoardPolicy,
}
impl BoardLayoutEngine {
const fn new(policy: SetupBoardPolicy) -> Self {
Self { policy }
}
fn layout<'a>(
&self,
spots: &'a [BoardSpot],
connections: &[BoardConnection],
) -> BoardLayout<'a> {
let layers = Self::layers(spots, connections);
let max_layer_width = layers
.values()
.map(|layer| self.layer_width(layer.len()))
.max()
.unwrap_or(self.policy.spot_width);
let mut laid_out = Vec::new();
let mut rects = BTreeMap::new();
for (layer_index, layer) in &layers {
let layer_width = self.layer_width(layer.len());
let x_offset = self.policy.padding + max_layer_width.saturating_sub(layer_width) / 2;
let y = self.policy.padding
+ layer_index.saturating_mul(self.policy.spot_height + self.policy.vertical_gap);
for (index, spot) in layer.iter().enumerate() {
let x = x_offset
+ u16::try_from(index)
.unwrap_or(u16::MAX)
.saturating_mul(self.policy.spot_width + self.policy.horizontal_gap);
let rect = Rect::new(x, y, self.policy.spot_width, self.policy.spot_height);
rects.insert(spot.id, rect);
laid_out.push(LaidOutBoardSpot { model: spot, rect });
}
}
let paths = connections
.iter()
.filter_map(|connection| {
Some(LaidOutBoardPath {
from: *rects.get(&connection.from)?,
to: *rects.get(&connection.to)?,
})
})
.collect::<Vec<_>>();
let height = u16::try_from(layers.len())
.unwrap_or(u16::MAX)
.saturating_mul(self.policy.spot_height + self.policy.vertical_gap)
.saturating_add(self.policy.padding);
BoardLayout {
width: max_layer_width.saturating_add(self.policy.padding.saturating_mul(2)),
height,
spots: laid_out,
paths,
}
}
fn layers<'a>(
spots: &'a [BoardSpot],
connections: &[BoardConnection],
) -> BTreeMap<u16, Vec<&'a BoardSpot>> {
let mut hinted = BTreeMap::<u16, Vec<&BoardSpot>>::new();
let mut unhinted = Vec::new();
for spot in spots {
if let Some(layer) = spot.hint.preferred_layer {
hinted.entry(layer).or_default().push(spot);
} else {
unhinted.push(spot);
}
}
let depths = graph_depths(spots, connections);
for spot in unhinted {
hinted
.entry(*depths.get(&spot.id).unwrap_or(&0))
.or_default()
.push(spot);
}
hinted
}
fn layer_width(&self, spot_count: usize) -> u16 {
let count = u16::try_from(spot_count.max(1)).unwrap_or(u16::MAX);
count.saturating_mul(self.policy.spot_width).saturating_add(
count
.saturating_sub(1)
.saturating_mul(self.policy.horizontal_gap),
)
}
}
fn graph_depths(
spots: &[BoardSpot],
connections: &[BoardConnection],
) -> BTreeMap<SetupSectionId, u16> {
let all = spots.iter().map(|spot| spot.id).collect::<BTreeSet<_>>();
let incoming = connections
.iter()
.map(|connection| connection.to)
.collect::<BTreeSet<_>>();
let roots = all.difference(&incoming).copied().collect::<Vec<_>>();
let mut depths = BTreeMap::new();
for root in roots {
assign_depth(root, 0, connections, &mut depths);
}
depths
}
fn assign_depth(
id: SetupSectionId,
depth: u16,
connections: &[BoardConnection],
depths: &mut BTreeMap<SetupSectionId, u16>,
) {
if depths.get(&id).is_some_and(|known| *known >= depth) {
return;
}
depths.insert(id, depth);
for child in connections
.iter()
.filter(|connection| connection.from == id)
{
assign_depth(child.to, depth.saturating_add(1), connections, depths);
}
}
struct BoardSpotComponent<'a> {
spot: &'a LaidOutBoardSpot<'a>,
}
impl<'a> BoardSpotComponent<'a> {
const fn new(spot: &'a LaidOutBoardSpot<'a>) -> Self {
Self { spot }
}
fn render(
&self,
surface: &mut BoardSurface,
state: &SetupBoardState,
theme: Option<&PresentedTheme>,
) {
let rect = self.spot.rect;
let model = self.spot.model;
let style = spot_style(model, state, theme);
surface.draw_box(rect, style);
surface.write_text(
rect.x.saturating_add(2),
rect.y.saturating_add(1),
rect.width.saturating_sub(4),
&model.title,
style,
);
surface.write_text(
rect.x.saturating_add(2),
rect.y.saturating_add(2),
rect.width.saturating_sub(4),
&model.subtitle,
style,
);
let glyph = status_glyph(model.status);
let glyph_x = rect.x.saturating_add(rect.width / 2).saturating_sub(1);
surface.put(
glyph_x,
rect.y.saturating_add(rect.height).saturating_sub(1),
glyph,
style,
);
}
}
#[derive(Clone, PartialEq, Eq)]
struct BoardCell {
text: String,
style: Style,
}
impl Default for BoardCell {
fn default() -> Self {
Self {
text: " ".to_owned(),
style: Style::new(),
}
}
}
struct BoardSurface {
width: u16,
height: u16,
cells: Vec<BoardCell>,
}
impl BoardSurface {
fn new(width: u16, height: u16) -> Self {
Self {
width,
height,
cells: vec![BoardCell::default(); usize::from(width) * usize::from(height)],
}
}
fn put(&mut self, x: u16, y: u16, text: &str, style: Style) {
if x >= self.width || y >= self.height {
return;
}
let index = usize::from(y) * usize::from(self.width) + usize::from(x);
self.cells[index] = BoardCell {
text: text.to_owned(),
style,
};
}
fn write_text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) {
for (offset, ch) in text.chars().take(usize::from(width)).enumerate() {
self.put(
x.saturating_add(u16::try_from(offset).unwrap_or(u16::MAX)),
y,
&ch.to_string(),
style,
);
}
}
fn draw_box(&mut self, rect: Rect, style: Style) {
let right = rect.x.saturating_add(rect.width).saturating_sub(1);
let bottom = rect.y.saturating_add(rect.height).saturating_sub(1);
self.put(rect.x, rect.y, "╭", style);
self.put(right, rect.y, "╮", style);
self.put(rect.x, bottom, "╰", style);
self.put(right, bottom, "╯", style);
self.draw_horizontal(
rect.x.saturating_add(1),
right.saturating_sub(1),
rect.y,
"─",
style,
);
self.draw_horizontal(
rect.x.saturating_add(1),
right.saturating_sub(1),
bottom,
"─",
style,
);
self.draw_vertical(
rect.x,
rect.y.saturating_add(1),
bottom.saturating_sub(1),
"│",
style,
);
self.draw_vertical(
right,
rect.y.saturating_add(1),
bottom.saturating_sub(1),
"│",
style,
);
}
fn draw_horizontal(&mut self, from_x: u16, to_x: u16, y: u16, text: &str, style: Style) {
for x in from_x..=to_x {
self.put(x, y, text, style);
}
}
fn draw_vertical(&mut self, x: u16, from_y: u16, to_y: u16, text: &str, style: Style) {
for y in from_y..=to_y {
self.put(x, y, text, style);
}
}
fn into_lines(self) -> Vec<Line> {
(0..self.height)
.map(|y| {
let mut spans = Vec::new();
let mut current_style = Style::new();
let mut current = String::new();
for x in 0..self.width {
let cell =
&self.cells[usize::from(y) * usize::from(self.width) + usize::from(x)];
if !current.is_empty() && cell.style != current_style {
spans.push(Span::styled(std::mem::take(&mut current), current_style));
}
current_style = cell.style;
current.push_str(&cell.text);
}
if !current.is_empty() {
spans.push(Span::styled(current, current_style));
}
Line::from_spans(spans)
})
.collect()
}
}
fn spot_style(spot: &BoardSpot, state: &SetupBoardState, theme: Option<&PresentedTheme>) -> Style {
let fallback = super::theme::resolve_initial_theme()
.presented(super::theme::resolve_initial_theme().accent);
let theme = theme.unwrap_or(&fallback);
let semantic_state = match spot.status {
SetupSectionStatus::Complete | SetupSectionStatus::Secured => {
bcode_tui_components::setup::SetupSpotState::Complete
}
SetupSectionStatus::Current => bcode_tui_components::setup::SetupSpotState::Current,
SetupSectionStatus::Recommended => bcode_tui_components::setup::SetupSpotState::Recommended,
SetupSectionStatus::Blocked | SetupSectionStatus::NeedsAttention => {
bcode_tui_components::setup::SetupSpotState::Blocked
}
SetupSectionStatus::Visited => bcode_tui_components::setup::SetupSpotState::Visited,
SetupSectionStatus::Optional
| SetupSectionStatus::Skipped
| SetupSectionStatus::Unvisited => bcode_tui_components::setup::SetupSpotState::Inactive,
};
bcode_tui_components::setup::setup_spot_style(
semantic_state,
state.focused == spot.id,
state.hovered == Some(spot.id),
state.pressed == Some(spot.id),
theme.component_theme(),
)
}
fn path_style(theme: Option<&PresentedTheme>) -> Style {
let fallback = super::theme::resolve_initial_theme()
.presented(super::theme::resolve_initial_theme().accent);
bcode_tui_components::setup::setup_path_style(theme.unwrap_or(&fallback).component_theme())
}
const fn status_glyph(status: SetupSectionStatus) -> &'static str {
match status {
SetupSectionStatus::Complete => "✓",
SetupSectionStatus::Secured => "🔒",
SetupSectionStatus::Current => "●",
SetupSectionStatus::Visited => "◐",
SetupSectionStatus::Recommended => "◆",
SetupSectionStatus::Optional | SetupSectionStatus::Unvisited => "○",
SetupSectionStatus::Skipped => "·",
SetupSectionStatus::Blocked | SetupSectionStatus::NeedsAttention => "!",
}
}
const fn board_scroll_policy() -> TextViewPolicy {
TextViewPolicy {
wrap: bmux_tui::text_block::TextWrap::None,
..TextViewPolicy::scrollable()
}
}
#[cfg(test)]
mod tests {
use super::*;
const fn rects_intersect(a: Rect, b: Rect) -> bool {
let a_right = a.x.saturating_add(a.width);
let b_right = b.x.saturating_add(b.width);
let a_bottom = a.y.saturating_add(a.height);
let b_bottom = b.y.saturating_add(b.height);
a.x < b_right && b.x < a_right && a.y < b_bottom && b.y < a_bottom
}
fn line_text(line: &Line) -> String {
line.spans
.iter()
.map(|span| span.content.as_str())
.collect::<String>()
}
#[test]
fn board_remap_and_unbind_have_no_native_fallback() {
let spots = vec![BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let mut config = bcode_config::TuiConfig::default();
config.keybindings.scopes.insert(
"setup".into(),
std::collections::BTreeMap::from([
("ctrl+y".into(), "tui.control.submit".into()),
("space".into(), String::new()),
]),
);
let keymap = super::super::keymap::BmuxKeyMap::try_from_config(&config).unwrap();
for key in ["enter", "space"] {
let event = Event::Key(bmux_keyboard::parse_key_stroke(key).unwrap());
assert_eq!(
board.handle_event_with_keymap(
Rect::new(0, 0, 80, 24),
&mut state,
&event,
&keymap
),
SetupBoardOutcome::Ignored,
);
}
let event = Event::Key(bmux_keyboard::parse_key_stroke("ctrl+y").unwrap());
assert_eq!(
board.handle_event_with_keymap(Rect::new(0, 0, 80, 24), &mut state, &event, &keymap),
SetupBoardOutcome::Selected(SetupSectionId::Welcome),
);
}
#[test]
fn layout_auto_spaces_spots_without_absolute_coordinates() {
let spots = vec![
BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)
.layer(0),
BoardSpot::new(
SetupSectionId::Detection,
"Detection",
"Scout Tower",
SetupSectionStatus::Complete,
)
.layer(1),
BoardSpot::new(
SetupSectionId::Providers,
"Providers",
"Signal Station",
SetupSectionStatus::Recommended,
)
.layer(1),
];
let connections = vec![
BoardConnection::new(SetupSectionId::Welcome, SetupSectionId::Detection),
BoardConnection::new(SetupSectionId::Welcome, SetupSectionId::Providers),
];
let layout = SetupBoard::new(&spots, &connections).layout();
assert_eq!(layout.spots.len(), 3);
assert_eq!(layout.paths.len(), 2);
assert!(!rects_intersect(layout.spots[1].rect, layout.spots[2].rect));
}
#[test]
fn render_lines_include_spot_labels_and_status_glyphs() {
let spots = vec![BoardSpot::new(
SetupSectionId::SecureVault,
"Secure Vault",
"Lockbox",
SetupSectionStatus::Secured,
)];
let state = SetupBoardState::new(SetupSectionId::SecureVault);
let lines = SetupBoard::new(&spots, &[]).render_lines(&state);
let text = lines.iter().map(line_text).collect::<Vec<_>>().join("\n");
assert!(text.contains("Secure Vault"));
assert!(text.contains("Lockbox"));
assert!(text.contains("🔒"));
}
#[test]
fn any_drag_from_spot_cancels_click() {
let spots = vec![BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let area = Rect::new(0, 0, 8, 4);
let _outcome = board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Point::new(3, 3),
)),
);
let _outcome = board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
Point::new(4, 3),
)),
);
assert_eq!(state.pressed, None);
assert_ne!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
Point::new(4, 3),
)),
),
SetupBoardOutcome::Selected(SetupSectionId::Welcome)
);
}
#[test]
fn keyboard_navigation_focuses_neighboring_spots() {
let spots = vec![
BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)
.layer(0),
BoardSpot::new(
SetupSectionId::Detection,
"Detection",
"Scout Tower",
SetupSectionStatus::Complete,
)
.layer(1),
BoardSpot::new(
SetupSectionId::Providers,
"Providers",
"Signal Station",
SetupSectionStatus::Recommended,
)
.layer(1),
];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let area = Rect::new(0, 0, 80, 20);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Key(bmux_keyboard::KeyStroke::simple(KeyCode::Down)),
),
SetupBoardOutcome::Focused(SetupSectionId::Detection)
);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Key(bmux_keyboard::KeyStroke::simple(KeyCode::Enter)),
),
SetupBoardOutcome::Selected(SetupSectionId::Detection)
);
}
#[test]
fn dragging_from_spot_pans_without_losing_drag_anchor() {
let spots = vec![BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let area = Rect::new(0, 0, 8, 4);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Point::new(3, 3)
)),
),
SetupBoardOutcome::Redraw
);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Drag(MouseButton::Left),
Point::new(1, 3)
)),
),
SetupBoardOutcome::Panned
);
assert_eq!(state.scroll.horizontal_offset(), 2);
}
#[test]
fn click_position_uses_board_area_relative_coordinates() {
let spots = vec![BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let area = Rect::new(10, 5, 40, 10);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Point::new(13, 8)
)),
),
SetupBoardOutcome::Redraw
);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
Point::new(13, 8)
)),
),
SetupBoardOutcome::Selected(SetupSectionId::Welcome)
);
}
#[test]
fn clicking_spot_selects_it() {
let spots = vec![BoardSpot::new(
SetupSectionId::Welcome,
"Welcome",
"Base Camp",
SetupSectionStatus::Current,
)];
let board = SetupBoard::new(&spots, &[]);
let mut state = SetupBoardState::new(SetupSectionId::Welcome);
let area = Rect::new(0, 0, 40, 10);
assert!(
board
.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Down(MouseButton::Left),
Point::new(3, 3)
)),
)
.is_handled()
);
assert_eq!(
board.handle_event(
area,
&mut state,
&Event::Mouse(MouseEvent::new(
MouseEventKind::Up(MouseButton::Left),
Point::new(3, 3)
)),
),
SetupBoardOutcome::Selected(SetupSectionId::Welcome)
);
}
}