This commit is contained in:
@@ -7,6 +7,7 @@ use std::fmt;
|
||||
|
||||
pub const GRID_ROWS: usize = 2;
|
||||
pub const GRID_COLUMNS: usize = 3;
|
||||
pub const INITIAL_ATTACHED_PANEL_COUNT: usize = 5;
|
||||
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
|
||||
|
||||
/// Stable identity used in traces and accessibility identifiers.
|
||||
@@ -282,6 +283,8 @@ pub enum FocusDirection {
|
||||
pub enum ShellAction {
|
||||
MoveFocus(FocusDirection),
|
||||
SelectPane(PaneId),
|
||||
AttachPanel(PaneId),
|
||||
DetachPanel(PaneId),
|
||||
SetNeedsInput { pane: PaneId, needs_input: bool },
|
||||
OpenCommandPalette,
|
||||
CloseCommandPalette,
|
||||
@@ -301,6 +304,9 @@ pub struct MeasurementCounters {
|
||||
pub focus_moves: u64,
|
||||
pub blocked_focus_moves: u64,
|
||||
pub direct_selections: u64,
|
||||
pub panel_attaches: u64,
|
||||
pub panel_detaches: u64,
|
||||
pub blocked_panel_detaches: u64,
|
||||
pub needs_input_changes: u64,
|
||||
pub palette_changes: u64,
|
||||
pub synthetic_ticks: u64,
|
||||
@@ -344,6 +350,7 @@ impl std::error::Error for InvalidExternalOutputPane {}
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ShellModel {
|
||||
panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
|
||||
attached_panels: [bool; GRID_ROWS * GRID_COLUMNS],
|
||||
selected_pane: PaneId,
|
||||
command_palette: CommandPaletteState,
|
||||
terminal_line_limit: usize,
|
||||
@@ -381,6 +388,7 @@ impl ShellModel {
|
||||
});
|
||||
Ok(Self {
|
||||
panes,
|
||||
attached_panels: std::array::from_fn(|index| index < INITIAL_ATTACHED_PANEL_COUNT),
|
||||
selected_pane: PaneId::CodexRuntime,
|
||||
command_palette: CommandPaletteState::default(),
|
||||
terminal_line_limit: limit,
|
||||
@@ -406,6 +414,14 @@ impl ShellModel {
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Creates the legacy six-card comparison state used by the Floem spike.
|
||||
#[must_use]
|
||||
pub fn with_all_panels_attached() -> Self {
|
||||
let mut model = Self::default();
|
||||
model.attached_panels.fill(true);
|
||||
model
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
|
||||
&self.panes
|
||||
@@ -415,6 +431,41 @@ impl ShellModel {
|
||||
&self.panes[id.index()]
|
||||
}
|
||||
#[must_use]
|
||||
pub fn attached_pane_ids(&self) -> Vec<PaneId> {
|
||||
PaneId::ALL
|
||||
.into_iter()
|
||||
.filter(|pane| self.is_panel_attached(*pane))
|
||||
.collect()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn detached_pane_ids(&self) -> Vec<PaneId> {
|
||||
PaneId::ALL
|
||||
.into_iter()
|
||||
.filter(|pane| !self.is_panel_attached(*pane))
|
||||
.collect()
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn is_panel_attached(&self, pane: PaneId) -> bool {
|
||||
self.attached_panels[pane.index()]
|
||||
}
|
||||
#[must_use]
|
||||
pub fn attached_panel_count(&self) -> usize {
|
||||
self.attached_panels
|
||||
.iter()
|
||||
.filter(|attached| **attached)
|
||||
.count()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn detached_panel_count(&self) -> usize {
|
||||
self.attached_panels.len() - self.attached_panel_count()
|
||||
}
|
||||
#[must_use]
|
||||
pub fn next_detached_panel(&self) -> Option<PaneId> {
|
||||
PaneId::ALL
|
||||
.into_iter()
|
||||
.find(|pane| !self.is_panel_attached(*pane))
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn selected_pane(&self) -> PaneId {
|
||||
self.selected_pane
|
||||
}
|
||||
@@ -445,7 +496,9 @@ impl ShellModel {
|
||||
let mut appended = 0;
|
||||
match action {
|
||||
ShellAction::MoveFocus(direction) => {
|
||||
if let Some(next) = focus_neighbor(self.selected_pane, direction) {
|
||||
if let Some(next) =
|
||||
attached_focus_neighbor(&self.attached_panels, self.selected_pane, direction)
|
||||
{
|
||||
self.selected_pane = next;
|
||||
self.counters.focus_moves += 1;
|
||||
changed = true;
|
||||
@@ -454,12 +507,41 @@ impl ShellModel {
|
||||
}
|
||||
}
|
||||
ShellAction::SelectPane(pane) => {
|
||||
if self.selected_pane != pane {
|
||||
if self.is_panel_attached(pane) && self.selected_pane != pane {
|
||||
self.selected_pane = pane;
|
||||
self.counters.direct_selections += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::AttachPanel(pane) => {
|
||||
if !self.is_panel_attached(pane) {
|
||||
self.attached_panels[pane.index()] = true;
|
||||
self.selected_pane = pane;
|
||||
self.counters.panel_attaches += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::DetachPanel(pane) => {
|
||||
if self.is_panel_attached(pane) {
|
||||
if self.attached_panel_count() == 1 {
|
||||
self.counters.blocked_panel_detaches += 1;
|
||||
} else {
|
||||
let attached_before = self.attached_pane_ids();
|
||||
let detached_position = attached_before
|
||||
.iter()
|
||||
.position(|candidate| *candidate == pane)
|
||||
.expect("attached panel must appear in attached IDs");
|
||||
self.attached_panels[pane.index()] = false;
|
||||
if self.selected_pane == pane {
|
||||
let remaining = self.attached_pane_ids();
|
||||
self.selected_pane =
|
||||
remaining[detached_position.min(remaining.len().saturating_sub(1))];
|
||||
}
|
||||
self.counters.panel_detaches += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
ShellAction::SetNeedsInput { pane, needs_input } => {
|
||||
let pane = &mut self.panes[pane.index()];
|
||||
if needs_input && !pane.needs_input() {
|
||||
@@ -570,13 +652,9 @@ impl ShellModel {
|
||||
|
||||
#[must_use]
|
||||
pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<PaneId> {
|
||||
let row = pane.row();
|
||||
let column = pane.column();
|
||||
let next = match direction {
|
||||
FocusDirection::Left if column > 0 => Some(pane.index() - 1),
|
||||
FocusDirection::Right if column + 1 < GRID_COLUMNS => Some(pane.index() + 1),
|
||||
FocusDirection::Up if row > 0 => Some(pane.index() - GRID_COLUMNS),
|
||||
FocusDirection::Down if row + 1 < GRID_ROWS => Some(pane.index() + GRID_COLUMNS),
|
||||
FocusDirection::Left if pane.index() > 0 => Some(pane.index() - 1),
|
||||
FocusDirection::Right if pane.index() + 1 < PaneId::ALL.len() => Some(pane.index() + 1),
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
@@ -585,6 +663,25 @@ pub const fn focus_neighbor(pane: PaneId, direction: FocusDirection) -> Option<P
|
||||
}
|
||||
}
|
||||
|
||||
fn attached_focus_neighbor(
|
||||
attached: &[bool; GRID_ROWS * GRID_COLUMNS],
|
||||
pane: PaneId,
|
||||
direction: FocusDirection,
|
||||
) -> Option<PaneId> {
|
||||
match direction {
|
||||
FocusDirection::Left => PaneId::ALL[..pane.index()]
|
||||
.iter()
|
||||
.rev()
|
||||
.copied()
|
||||
.find(|candidate| attached[candidate.index()]),
|
||||
FocusDirection::Right => PaneId::ALL[pane.index() + 1..]
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| attached[candidate.index()]),
|
||||
FocusDirection::Up | FocusDirection::Down => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
@@ -613,6 +710,8 @@ mod tests {
|
||||
fn initial_state_has_one_explicit_needs_input_pane() {
|
||||
let model = ShellModel::default();
|
||||
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
|
||||
assert_eq!(model.attached_panel_count(), 5);
|
||||
assert_eq!(model.detached_pane_ids(), vec![PaneId::RuntimeReview]);
|
||||
assert_eq!(model.revision(), 0);
|
||||
assert!(!model.command_palette().is_open());
|
||||
assert_eq!(
|
||||
@@ -627,24 +726,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbors_match_two_by_three_grid_without_wrapping() {
|
||||
fn legacy_comparison_constructor_attaches_all_six_without_an_event() {
|
||||
let model = ShellModel::with_all_panels_attached();
|
||||
assert_eq!(model.attached_panel_count(), PaneId::ALL.len());
|
||||
assert_eq!(model.revision(), 0);
|
||||
assert_eq!(model.counters().events_dispatched, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbors_follow_the_horizontal_panel_row_without_wrapping() {
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
|
||||
Some(PaneId::ClaudeUi)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Down),
|
||||
Some(PaneId::AcpPreview)
|
||||
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Left),
|
||||
Some(PaneId::CodexRuntime)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up),
|
||||
Some(PaneId::PiDocs)
|
||||
focus_neighbor(PaneId::AcpPreview, FocusDirection::Right),
|
||||
Some(PaneId::RuntimeReview)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::Architecture, FocusDirection::Left),
|
||||
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Left),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Right),
|
||||
None
|
||||
);
|
||||
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
|
||||
None
|
||||
@@ -661,12 +771,79 @@ mod tests {
|
||||
assert!(moved.changed);
|
||||
assert_eq!(moved.selected_pane, PaneId::ClaudeUi);
|
||||
assert_eq!(moved.revision, 1);
|
||||
model.dispatch(ShellAction::MoveFocus(FocusDirection::Down));
|
||||
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
|
||||
model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
|
||||
assert_eq!(model.selected_pane(), PaneId::PiDocs);
|
||||
assert_eq!(model.counters().focus_moves, 2);
|
||||
assert_eq!(model.counters().blocked_focus_moves, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_is_reversible_and_keeps_the_surface_running() {
|
||||
let mut model = ShellModel::default();
|
||||
model.dispatch(ShellAction::SyntheticStreamTick);
|
||||
let sequence_before = model.pane(PaneId::ClaudeUi).synthetic_line_sequence();
|
||||
|
||||
let detached = model.dispatch(ShellAction::DetachPanel(PaneId::ClaudeUi));
|
||||
assert!(detached.changed);
|
||||
assert!(!model.is_panel_attached(PaneId::ClaudeUi));
|
||||
assert_eq!(model.attached_panel_count(), 4);
|
||||
|
||||
model.dispatch(ShellAction::SyntheticStreamTick);
|
||||
assert_eq!(
|
||||
model.pane(PaneId::ClaudeUi).synthetic_line_sequence(),
|
||||
sequence_before + 1
|
||||
);
|
||||
|
||||
let attached = model.dispatch(ShellAction::AttachPanel(PaneId::ClaudeUi));
|
||||
assert!(attached.changed);
|
||||
assert!(model.is_panel_attached(PaneId::ClaudeUi));
|
||||
assert_eq!(model.selected_pane(), PaneId::ClaudeUi);
|
||||
assert_eq!(model.counters().panel_detaches, 1);
|
||||
assert_eq!(model.counters().panel_attaches, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detaching_selected_panel_prefers_the_next_sibling_then_previous() {
|
||||
let mut model = ShellModel::default();
|
||||
model.dispatch(ShellAction::SelectPane(PaneId::PiDocs));
|
||||
model.dispatch(ShellAction::DetachPanel(PaneId::PiDocs));
|
||||
assert_eq!(model.selected_pane(), PaneId::Architecture);
|
||||
|
||||
model.dispatch(ShellAction::SelectPane(PaneId::AcpPreview));
|
||||
model.dispatch(ShellAction::DetachPanel(PaneId::AcpPreview));
|
||||
assert_eq!(model.selected_pane(), PaneId::Architecture);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_attached_panel_cannot_be_detached() {
|
||||
let mut model = ShellModel::default();
|
||||
for pane in [
|
||||
PaneId::ClaudeUi,
|
||||
PaneId::PiDocs,
|
||||
PaneId::Architecture,
|
||||
PaneId::AcpPreview,
|
||||
] {
|
||||
assert!(model.dispatch(ShellAction::DetachPanel(pane)).changed);
|
||||
}
|
||||
assert_eq!(model.attached_pane_ids(), vec![PaneId::CodexRuntime]);
|
||||
let blocked = model.dispatch(ShellAction::DetachPanel(PaneId::CodexRuntime));
|
||||
assert!(!blocked.changed);
|
||||
assert_eq!(model.attached_panel_count(), 1);
|
||||
assert_eq!(model.counters().blocked_panel_detaches, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_panels_cannot_take_keyboard_focus() {
|
||||
let mut model = ShellModel::default();
|
||||
let outcome = model.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview));
|
||||
assert!(!outcome.changed);
|
||||
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
|
||||
|
||||
model.dispatch(ShellAction::DetachPanel(PaneId::ClaudeUi));
|
||||
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
|
||||
assert_eq!(moved.selected_pane, PaneId::PiDocs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_is_idempotent_and_counted_separately() {
|
||||
let mut model = ShellModel::default();
|
||||
@@ -677,10 +854,10 @@ mod tests {
|
||||
);
|
||||
assert!(
|
||||
model
|
||||
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview))
|
||||
.dispatch(ShellAction::SelectPane(PaneId::AcpPreview))
|
||||
.changed
|
||||
);
|
||||
assert_eq!(model.selected_pane(), PaneId::RuntimeReview);
|
||||
assert_eq!(model.selected_pane(), PaneId::AcpPreview);
|
||||
assert_eq!(model.counters().direct_selections, 1);
|
||||
assert_eq!(model.counters().events_dispatched, 2);
|
||||
assert_eq!(model.counters().state_changes, 1);
|
||||
@@ -829,7 +1006,8 @@ mod tests {
|
||||
fn identical_action_replays_produce_identical_models_and_counters() {
|
||||
let actions = [
|
||||
ShellAction::MoveFocus(FocusDirection::Right),
|
||||
ShellAction::MoveFocus(FocusDirection::Down),
|
||||
ShellAction::DetachPanel(PaneId::PiDocs),
|
||||
ShellAction::AttachPanel(PaneId::RuntimeReview),
|
||||
ShellAction::OpenCommandPalette,
|
||||
ShellAction::SetCommandPaletteQuery("workspace".into()),
|
||||
ShellAction::CloseCommandPalette,
|
||||
|
||||
Reference in New Issue
Block a user