This commit is contained in:
+421
-91
@@ -1,151 +1,481 @@
|
||||
use floem::{Application, kurbo::Size, peniko::Color, prelude::*, window::WindowConfig};
|
||||
use std::time::Duration;
|
||||
|
||||
use floem::{
|
||||
Application,
|
||||
action::exec_after,
|
||||
event::EventPropagation,
|
||||
kurbo::Size,
|
||||
peniko::Color,
|
||||
prelude::*,
|
||||
reactive::{Effect, RwSignal, SignalGet, SignalTrack, SignalUpdate, SignalWith},
|
||||
style::Position,
|
||||
window::WindowConfig,
|
||||
};
|
||||
use lumbridge_spike_model::{
|
||||
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
|
||||
FOOTER_CENTER, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
|
||||
SurfaceKind, WORKSPACES,
|
||||
};
|
||||
|
||||
const BG: Color = Color::from_rgb8(12, 14, 19);
|
||||
const PANEL: Color = Color::from_rgb8(18, 23, 34);
|
||||
const PANEL_ALT: Color = Color::from_rgb8(23, 29, 41);
|
||||
const BORDER: Color = Color::from_rgb8(41, 50, 68);
|
||||
const TEXT: Color = Color::from_rgb8(217, 226, 242);
|
||||
const MUTED: Color = Color::from_rgb8(127, 139, 163);
|
||||
const ACCENT: Color = Color::from_rgb8(119, 189, 251);
|
||||
const BG: Color = Color::from_rgb8(9, 12, 18);
|
||||
const PANEL: Color = Color::from_rgb8(16, 22, 32);
|
||||
const PANEL_ALT: Color = Color::from_rgb8(21, 29, 41);
|
||||
const PANEL_ACTIVE: Color = Color::from_rgb8(24, 35, 52);
|
||||
const BORDER: Color = Color::from_rgb8(38, 50, 70);
|
||||
const BORDER_QUIET: Color = Color::from_rgb8(28, 38, 54);
|
||||
const TEXT: Color = Color::from_rgb8(219, 229, 244);
|
||||
const MUTED: Color = Color::from_rgb8(130, 144, 168);
|
||||
const ACCENT: Color = Color::from_rgb8(104, 181, 248);
|
||||
const ATTENTION: Color = Color::from_rgb8(241, 185, 106);
|
||||
const SUCCESS: Color = Color::from_rgb8(112, 214, 168);
|
||||
const OS_MOD: Modifiers = if cfg!(target_os = "macos") {
|
||||
Modifiers::META
|
||||
} else {
|
||||
Modifiers::CONTROL
|
||||
};
|
||||
|
||||
fn pane_card(pane: PaneFixture) -> impl IntoView {
|
||||
let header = Stack::horizontal((
|
||||
Label::new(pane.title).style(|s| s.flex_grow(1.0).color(TEXT)),
|
||||
Label::new(pane.badge).style(|s| s.font_size(11.0).color(ACCENT)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.items_center()
|
||||
.height(34.0)
|
||||
.padding_horiz(10.0)
|
||||
.background(PANEL_ALT)
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER)
|
||||
fn dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
|
||||
model.update(|model| {
|
||||
model.dispatch(action);
|
||||
});
|
||||
}
|
||||
|
||||
let lines =
|
||||
Stack::vertical(pane.lines.map(|line| {
|
||||
Label::new(line).style(|s| s.font_size(12.0).color(TEXT).margin_bottom(5.0))
|
||||
}));
|
||||
|
||||
Stack::vertical((
|
||||
header,
|
||||
Label::new(pane.target).style(|s| s.font_size(11.0).color(MUTED).padding(10.0)),
|
||||
lines.style(|s| s.padding_horiz(10.0).padding_bottom(10.0)),
|
||||
))
|
||||
fn pane_card(id: PaneId, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||
dyn_container(
|
||||
move || model.with(|model| (model.pane(id).clone(), model.selected_pane() == id)),
|
||||
move |(pane, selected)| render_pane_card(pane, selected, model),
|
||||
)
|
||||
.style(|s| {
|
||||
s.flex_basis(0)
|
||||
.flex_grow(1.0)
|
||||
.min_width(0.0)
|
||||
.min_height(0.0)
|
||||
.margin(4.0)
|
||||
.background(PANEL)
|
||||
.border(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_radius(6.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn app_view() -> impl IntoView {
|
||||
let sidebar_items = Stack::vertical(WORKSPACES.map(|name| {
|
||||
Label::new(name).style(move |s| {
|
||||
s.width_full()
|
||||
.padding_vert(8.0)
|
||||
.padding_horiz(12.0)
|
||||
.margin_bottom(3.0)
|
||||
.color(if name == "Lumbridge Code" {
|
||||
TEXT
|
||||
} else {
|
||||
MUTED
|
||||
})
|
||||
.apply_if(name == "Lumbridge Code", |s| {
|
||||
s.background(PANEL_ALT).border_radius(6.0)
|
||||
})
|
||||
fn render_pane_card(pane: PaneState, selected: bool, model: RwSignal<ShellModel>) -> impl IntoView {
|
||||
let id = pane.id();
|
||||
let needs_input = pane.needs_input();
|
||||
let state_color = if needs_input {
|
||||
ATTENTION
|
||||
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||
SUCCESS
|
||||
} else {
|
||||
ACCENT
|
||||
};
|
||||
let state_label = if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||
pane.status().label()
|
||||
} else {
|
||||
pane.fixture().badge
|
||||
};
|
||||
let start = pane.lines().len().saturating_sub(12);
|
||||
let lines = Stack::from_iter(pane.lines()[start..].iter().cloned().map(|line| {
|
||||
Label::new(line).style(|s| {
|
||||
s.font_size(12.0)
|
||||
.color(TEXT)
|
||||
.margin_bottom(4.0)
|
||||
.min_width(0.0)
|
||||
})
|
||||
}));
|
||||
}))
|
||||
.style(|s| s.flex_col().padding_horiz(10.0).padding_bottom(10.0));
|
||||
|
||||
let sidebar = Stack::vertical((
|
||||
Label::new("WORKSPACES").style(|s| s.font_size(11.0).color(MUTED).padding(12.0)),
|
||||
sidebar_items.style(|s| s.padding_horiz(8.0)),
|
||||
Label::new("HOSTS").style(|s| {
|
||||
s.font_size(11.0)
|
||||
.color(MUTED)
|
||||
.padding(12.0)
|
||||
.margin_top(12.0)
|
||||
}),
|
||||
Label::new("● metal · connected").style(|s| s.color(ACCENT).padding_horiz(14.0)),
|
||||
Label::new("● amd-server · connected")
|
||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
||||
Label::new("○ spark-1 · sleeping")
|
||||
.style(|s| s.color(MUTED).padding_horiz(14.0).margin_top(8.0)),
|
||||
let header = Stack::horizontal((
|
||||
Stack::horizontal((
|
||||
Label::new(format!("{}", id.index() + 1))
|
||||
.style(|s| s.font_size(11.0).color(MUTED).margin_right(8.0)),
|
||||
Label::new(pane.fixture().title)
|
||||
.style(|s| s.flex_grow(1.0).font_size(13.0).color(TEXT)),
|
||||
))
|
||||
.style(|s| s.items_center().min_width(0.0).flex_grow(1.0)),
|
||||
Label::new(state_label).style(move |s| s.font_size(10.0).color(state_color)),
|
||||
))
|
||||
.style(move |s| {
|
||||
s.items_center()
|
||||
.height(36.0)
|
||||
.padding_horiz(10.0)
|
||||
.background(if selected { PANEL_ACTIVE } else { PANEL_ALT })
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER_QUIET)
|
||||
});
|
||||
|
||||
let target = Stack::horizontal((
|
||||
Label::new(pane.fixture().target).style(|s| s.flex_grow(1.0).color(MUTED)),
|
||||
Label::new(if selected { "FOCUSED" } else { "" })
|
||||
.style(|s| s.font_size(10.0).color(ACCENT)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.width(220.0)
|
||||
s.items_center()
|
||||
.padding_horiz(10.0)
|
||||
.padding_vert(8.0)
|
||||
.font_size(11.0)
|
||||
});
|
||||
|
||||
Stack::vertical((header, target, lines))
|
||||
.style(move |s| {
|
||||
s.width_full()
|
||||
.height_full()
|
||||
.min_width(0.0)
|
||||
.min_height(0.0)
|
||||
.background(if selected { PANEL_ACTIVE } else { PANEL })
|
||||
.border(if selected { 2.0 } else { 1.0 })
|
||||
.border_color(if selected || needs_input {
|
||||
state_color
|
||||
} else {
|
||||
BORDER
|
||||
})
|
||||
.border_radius(5.0)
|
||||
.keyboard_navigable()
|
||||
})
|
||||
.on_event_stop(listener::Click, move |_, _| {
|
||||
dispatch(model, ShellAction::SelectPane(id));
|
||||
})
|
||||
}
|
||||
|
||||
fn sidebar() -> impl IntoView {
|
||||
let worktrees = Stack::from_iter(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||
Stack::vertical((
|
||||
Label::new(name),
|
||||
Label::new(if index == 0 { "main · metal" } else { "" })
|
||||
.style(|s| s.font_size(10.0).color(MUTED).margin_top(2.0)),
|
||||
))
|
||||
.style(move |s| {
|
||||
s.width_full()
|
||||
.padding_vert(7.0)
|
||||
.padding_horiz(10.0)
|
||||
.margin_bottom(3.0)
|
||||
.color(if index == 0 { TEXT } else { MUTED })
|
||||
.apply_if(index == 0, |s| s.background(PANEL_ALT).border_radius(5.0))
|
||||
})
|
||||
}))
|
||||
.style(|s| s.flex_col().padding_horiz(8.0));
|
||||
|
||||
Stack::vertical((
|
||||
Label::new("ATTENTION · 1").style(|s| s.font_size(10.0).color(ATTENTION).padding(12.0)),
|
||||
Stack::vertical((
|
||||
Label::new("Claude Code · UI").style(|s| s.color(TEXT)),
|
||||
Label::new("Waiting for a split decision")
|
||||
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.margin_horiz(8.0)
|
||||
.margin_bottom(12.0)
|
||||
.padding(10.0)
|
||||
.background(PANEL_ACTIVE)
|
||||
.border(1.0)
|
||||
.border_color(ATTENTION)
|
||||
.border_radius(5.0)
|
||||
}),
|
||||
Label::new("WORKTREES").style(|s| s.font_size(10.0).color(MUTED).padding(12.0)),
|
||||
worktrees,
|
||||
Label::new("RUNTIMES").style(|s| {
|
||||
s.font_size(10.0)
|
||||
.color(MUTED)
|
||||
.padding_horiz(12.0)
|
||||
.padding_vert(9.0)
|
||||
.margin_top(8.0)
|
||||
}),
|
||||
Label::new("● metal · connected").style(|s| s.color(SUCCESS).padding_horiz(12.0)),
|
||||
Label::new("● amd-server · connected")
|
||||
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
||||
Label::new("○ spark-1 · sleeping")
|
||||
.style(|s| s.color(MUTED).padding_horiz(12.0).margin_top(7.0)),
|
||||
Empty::new().style(|s| s.flex_grow(1.0)),
|
||||
Stack::vertical((
|
||||
Label::new("Buzz · lumbridgecode"),
|
||||
Label::new("connected · signed identity").style(|s| s.color(SUCCESS).margin_top(3.0)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.margin(10.0)
|
||||
.padding(10.0)
|
||||
.font_size(10.0)
|
||||
.color(MUTED)
|
||||
.background(PANEL_ALT)
|
||||
.border_radius(5.0)
|
||||
}),
|
||||
))
|
||||
.style(|s| {
|
||||
s.width(248.0)
|
||||
.height_full()
|
||||
.flex_shrink(0.0)
|
||||
.background(PANEL)
|
||||
.border_right(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_color(BORDER_QUIET)
|
||||
})
|
||||
}
|
||||
|
||||
fn tabs() -> impl IntoView {
|
||||
Stack::horizontal((
|
||||
Label::new("Agent workspace").style(|s| {
|
||||
s.height_full()
|
||||
.items_center()
|
||||
.padding_horiz(12.0)
|
||||
.border_bottom(2.0)
|
||||
.border_color(ACCENT)
|
||||
.color(TEXT)
|
||||
}),
|
||||
Label::new("Architecture.md").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
||||
Label::new("Review").style(|s| s.padding_horiz(12.0).color(MUTED)),
|
||||
Empty::new().style(|s| s.flex_grow(1.0)),
|
||||
Label::new("6 surfaces · 3 remote · 1 waiting")
|
||||
.style(|s| s.font_size(10.0).padding_horiz(12.0).color(MUTED)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.height(38.0)
|
||||
.items_center()
|
||||
.font_size(12.0)
|
||||
.background(PANEL)
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER_QUIET)
|
||||
})
|
||||
}
|
||||
|
||||
fn command_palette(query: RwSignal<String>) -> impl IntoView {
|
||||
let input = TextInput::new(query)
|
||||
.placeholder("Type a command…")
|
||||
.style(|s| {
|
||||
s.width_full()
|
||||
.height(44.0)
|
||||
.padding_horiz(12.0)
|
||||
.color(TEXT)
|
||||
.background(PANEL_ALT)
|
||||
.border(0.0)
|
||||
});
|
||||
let input_id = input.id();
|
||||
exec_after(Duration::from_millis(1), move |_| input_id.request_focus());
|
||||
|
||||
let card = Stack::vertical((
|
||||
input,
|
||||
Stack::vertical((
|
||||
Stack::vertical((
|
||||
Label::new("Focus next pane").style(|s| s.color(TEXT)),
|
||||
Label::new("Workspace · navigation")
|
||||
.style(|s| s.font_size(10.0).color(MUTED).margin_top(3.0)),
|
||||
))
|
||||
.style(|s| s.padding(10.0).background(PANEL_ACTIVE).border_radius(5.0)),
|
||||
Label::new("Open attention request").style(|s| s.padding(10.0).color(MUTED)),
|
||||
Label::new("Share selected pane to Buzz…").style(|s| s.padding(10.0).color(MUTED)),
|
||||
))
|
||||
.style(|s| {
|
||||
s.padding(8.0)
|
||||
.border_top(1.0)
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER)
|
||||
}),
|
||||
Stack::horizontal((
|
||||
Label::new("Enter to run").style(|s| s.flex_grow(1.0)),
|
||||
Label::new("Esc to close"),
|
||||
))
|
||||
.style(|s| {
|
||||
s.padding_horiz(12.0)
|
||||
.padding_vert(8.0)
|
||||
.font_size(10.0)
|
||||
.color(MUTED)
|
||||
}),
|
||||
))
|
||||
.style(|s| {
|
||||
s.width(620.0)
|
||||
.margin_top(92.0)
|
||||
.background(PANEL_ALT)
|
||||
.border(1.0)
|
||||
.border_color(ACCENT)
|
||||
.border_radius(8.0)
|
||||
});
|
||||
|
||||
Container::new(card).style(|s| {
|
||||
s.position(Position::Absolute)
|
||||
.inset(0.0)
|
||||
.items_start()
|
||||
.justify_center()
|
||||
.background(Color::from_rgba8(5, 7, 11, 220))
|
||||
.z_index(50)
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_key(
|
||||
model: RwSignal<ShellModel>,
|
||||
query: RwSignal<String>,
|
||||
KeyboardEvent {
|
||||
code,
|
||||
key,
|
||||
modifiers,
|
||||
..
|
||||
}: &KeyboardEvent,
|
||||
) -> EventPropagation {
|
||||
if *code == Code::KeyK && modifiers.contains(OS_MOD) {
|
||||
query.set(String::new());
|
||||
dispatch(model, ShellAction::OpenCommandPalette);
|
||||
return EventPropagation::Stop;
|
||||
}
|
||||
|
||||
if model.with(|model| model.command_palette().is_open()) {
|
||||
if *key == Key::Named(NamedKey::Escape) {
|
||||
query.set(String::new());
|
||||
dispatch(model, ShellAction::CloseCommandPalette);
|
||||
return EventPropagation::Stop;
|
||||
}
|
||||
return EventPropagation::Continue;
|
||||
}
|
||||
|
||||
let action = match key {
|
||||
Key::Named(NamedKey::ArrowLeft) => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
||||
Key::Named(NamedKey::ArrowRight) => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
||||
Key::Named(NamedKey::ArrowUp) => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
||||
Key::Named(NamedKey::ArrowDown) => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
||||
Key::Character(character) if modifiers.is_empty() => match character.as_str() {
|
||||
"h" => Some(ShellAction::MoveFocus(FocusDirection::Left)),
|
||||
"l" => Some(ShellAction::MoveFocus(FocusDirection::Right)),
|
||||
"k" => Some(ShellAction::MoveFocus(FocusDirection::Up)),
|
||||
"j" => Some(ShellAction::MoveFocus(FocusDirection::Down)),
|
||||
"1" => Some(ShellAction::SelectPane(PaneId::CodexRuntime)),
|
||||
"2" => Some(ShellAction::SelectPane(PaneId::ClaudeUi)),
|
||||
"3" => Some(ShellAction::SelectPane(PaneId::PiDocs)),
|
||||
"4" => Some(ShellAction::SelectPane(PaneId::Architecture)),
|
||||
"5" => Some(ShellAction::SelectPane(PaneId::AcpPreview)),
|
||||
"6" => Some(ShellAction::SelectPane(PaneId::RuntimeReview)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(action) = action {
|
||||
dispatch(model, action);
|
||||
EventPropagation::Stop
|
||||
} else {
|
||||
EventPropagation::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn app_view() -> impl IntoView {
|
||||
let model = RwSignal::new(ShellModel::default());
|
||||
let query = RwSignal::new(String::new());
|
||||
let timer_pulse = RwSignal::new(());
|
||||
|
||||
Effect::new(move |_| {
|
||||
timer_pulse.track();
|
||||
exec_after(Duration::from_millis(650), move |_| {
|
||||
dispatch(model, ShellAction::SyntheticStreamTick);
|
||||
timer_pulse.set(());
|
||||
});
|
||||
});
|
||||
|
||||
Effect::new(move |_| {
|
||||
let query = query.get();
|
||||
model.update(|model| {
|
||||
if model.command_palette().is_open() && model.command_palette().query() != query {
|
||||
model.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let top_row = Stack::horizontal((
|
||||
pane_card(PANES[0]),
|
||||
pane_card(PANES[1]),
|
||||
pane_card(PANES[2]),
|
||||
pane_card(PaneId::CodexRuntime, model),
|
||||
pane_card(PaneId::ClaudeUi, model),
|
||||
pane_card(PaneId::PiDocs, model),
|
||||
))
|
||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
||||
let bottom_row = Stack::horizontal((
|
||||
pane_card(PANES[3]),
|
||||
pane_card(PANES[4]),
|
||||
pane_card(PANES[5]),
|
||||
pane_card(PaneId::Architecture, model),
|
||||
pane_card(PaneId::AcpPreview, model),
|
||||
pane_card(PaneId::RuntimeReview, model),
|
||||
))
|
||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0).width_full());
|
||||
let panes = Stack::vertical((top_row, bottom_row)).style(|s| {
|
||||
let grid = Stack::vertical((top_row, bottom_row)).style(|s| {
|
||||
s.flex_basis(0)
|
||||
.flex_grow(1.0)
|
||||
.min_width(0.0)
|
||||
.height_full()
|
||||
.min_height(0.0)
|
||||
.padding(4.0)
|
||||
});
|
||||
|
||||
let header = Stack::horizontal((
|
||||
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT).flex_grow(1.0)),
|
||||
Label::new("UI spike · Floem").style(|s| s.color(MUTED).flex_grow(1.0)),
|
||||
Label::new("⌘K Command Palette").style(|s| s.color(ACCENT)),
|
||||
Label::new("Lumbridge").style(|s| s.font_size(18.0).color(TEXT)),
|
||||
Label::new("Lumbridge Code / main")
|
||||
.style(|s| s.color(MUTED).margin_left(12.0).flex_grow(1.0)),
|
||||
Label::new("metal · runtime online")
|
||||
.style(|s| s.font_size(10.0).color(SUCCESS).margin_right(18.0)),
|
||||
Label::new("Ctrl/⌘ K · Commands")
|
||||
.style(|s| s.color(ACCENT).keyboard_navigable())
|
||||
.on_event_stop(listener::Click, move |_, _| {
|
||||
query.set(String::new());
|
||||
dispatch(model, ShellAction::OpenCommandPalette);
|
||||
}),
|
||||
))
|
||||
.style(|s| {
|
||||
s.height(46.0)
|
||||
s.height(48.0)
|
||||
.padding_horiz(14.0)
|
||||
.items_center()
|
||||
.background(PANEL_ALT)
|
||||
.border_bottom(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_color(BORDER_QUIET)
|
||||
});
|
||||
|
||||
let body = Stack::horizontal((sidebar, panes))
|
||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
|
||||
let body = Stack::horizontal((
|
||||
sidebar(),
|
||||
Stack::vertical((tabs(), grid)).style(|s| {
|
||||
s.flex_basis(0)
|
||||
.flex_grow(1.0)
|
||||
.min_width(0.0)
|
||||
.min_height(0.0)
|
||||
}),
|
||||
))
|
||||
.style(|s| s.flex_basis(0).flex_grow(1.0).min_height(0.0));
|
||||
|
||||
let footer = Stack::horizontal((
|
||||
Label::new(FOOTER_LEFT).style(|s| s.flex_grow(1.0)),
|
||||
Label::derived(move || {
|
||||
model.with(|model| {
|
||||
let counters = model.counters();
|
||||
format!(
|
||||
"rev {} · {} focus moves · {} surface updates · {} lines",
|
||||
model.revision(),
|
||||
counters.focus_moves,
|
||||
counters.surface_updates,
|
||||
counters.terminal_lines_appended
|
||||
)
|
||||
})
|
||||
})
|
||||
.style(|s| s.flex_grow(1.0)),
|
||||
Label::new(FOOTER_CENTER).style(|s| s.flex_grow(1.0)),
|
||||
Label::new(FOOTER_RIGHT),
|
||||
))
|
||||
.style(|s| {
|
||||
s.height(30.0)
|
||||
s.height(32.0)
|
||||
.padding_horiz(10.0)
|
||||
.items_center()
|
||||
.font_size(11.0)
|
||||
.font_size(10.0)
|
||||
.color(MUTED)
|
||||
.background(PANEL_ALT)
|
||||
.border_top(1.0)
|
||||
.border_color(BORDER)
|
||||
.border_color(BORDER_QUIET)
|
||||
});
|
||||
|
||||
Stack::vertical((header, body, footer))
|
||||
.style(|s| s.width_full().height_full().background(BG))
|
||||
.window_title(|| "Lumbridge · Floem spike".to_owned())
|
||||
let palette = dyn_container(
|
||||
move || model.with(|model| model.command_palette().is_open()),
|
||||
move |open| {
|
||||
if open {
|
||||
command_palette(query).into_any()
|
||||
} else {
|
||||
Empty::new().into_any()
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let root = Stack::vertical((header, body, footer, palette))
|
||||
.style(|s| {
|
||||
s.position(Position::Relative)
|
||||
.width_full()
|
||||
.height_full()
|
||||
.background(BG)
|
||||
.color(TEXT)
|
||||
.keyboard_navigable()
|
||||
})
|
||||
.on_event(listener::KeyDown, move |_, event| {
|
||||
handle_key(model, query, event)
|
||||
})
|
||||
.window_title(|| "Lumbridge · Floem workspace".to_owned());
|
||||
|
||||
let root_id = root.id();
|
||||
exec_after(Duration::from_millis(1), move |_| root_id.request_focus());
|
||||
root
|
||||
}
|
||||
|
||||
fn main() {
|
||||
@@ -154,9 +484,9 @@ fn main() {
|
||||
|_| app_view(),
|
||||
Some(
|
||||
WindowConfig::default()
|
||||
.size(Size::new(1280.0, 800.0))
|
||||
.size(Size::new(1500.0, 960.0))
|
||||
.min_size(Size::new(900.0, 600.0))
|
||||
.title("Lumbridge · Floem spike"),
|
||||
.title("Lumbridge · Floem workspace"),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
|
||||
+7119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "lumbridge-spike-gpui-accessibility"
|
||||
description = "Isolated proof of current GPUI accessibility and IME input for Lumbridge"
|
||||
version = "0.0.1"
|
||||
edition = "2024"
|
||||
rust-version = "1.97.1"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "ce48461eaadd16c65c31f835511ab96bd3b6e746", default-features = false, features = ["wayland", "x11"] }
|
||||
gpui_platform = { git = "https://github.com/zed-industries/zed.git", rev = "ce48461eaadd16c65c31f835511ab96bd3b6e746", default-features = false, features = ["font-kit", "wayland", "x11"] }
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,83 @@
|
||||
# GPUI accessibility and input probe
|
||||
|
||||
This independent workspace answers one narrow question: can the GPUI version
|
||||
currently used by Zed expose the minimum semantic tree Lumbridge needs for a
|
||||
workspace and terminal pane, while accepting real platform IME input?
|
||||
|
||||
## Reproducibility
|
||||
|
||||
- Upstream: `https://github.com/zed-industries/zed`
|
||||
- Commit: `ce48461eaadd16c65c31f835511ab96bd3b6e746`
|
||||
- GPUI license at this commit: Apache-2.0
|
||||
- Rust: 1.97.1, matching the upstream `rust-toolchain.toml`
|
||||
|
||||
Both `gpui` and `gpui_platform` are pinned to the full commit. This directory is
|
||||
an independent Cargo workspace, so its toolchain and dependency graph do not
|
||||
change Lumbridge's root MSRV or release graph.
|
||||
|
||||
## Semantics under test
|
||||
|
||||
The rendered tree contains:
|
||||
|
||||
- an application named `Lumbridge accessibility probe`;
|
||||
- a stable, externally identifiable `Region` named `Lumbridge workspace`;
|
||||
- a stable, focusable `Pane` named `Local terminal pane`;
|
||||
- `selected = true` on the pane;
|
||||
- real GPUI keyboard focus tracked on the pane;
|
||||
- the description `Needs input: choose whether to run the proposed command`;
|
||||
- a `Terminal` child named `Terminal output` for the pane's content.
|
||||
|
||||
Current GPUI exposes these through its AccessKit integration using `role`,
|
||||
`accessibility_id`, `aria_label`, `aria_description`, `aria_selected`,
|
||||
`focusable`, and `track_focus`. The deterministic unit test checks the exact
|
||||
AccessKit role and properties. The binary compile-checks GPUI's element wiring;
|
||||
a platform screen reader remains necessary for end-to-end AT-SPI/VoiceOver
|
||||
validation.
|
||||
|
||||
## Input and IME proof
|
||||
|
||||
The focused pane also installs a real `ElementInputHandler<ImeTextReceiver>`
|
||||
during element paint. `ImeTextReceiver` implements GPUI's
|
||||
`EntityInputHandler`, including text queries, UTF-16 selection, marked-text
|
||||
composition, replacement, unmarking, selection updates, editable length, and
|
||||
IME candidate bounds. This is the code path GPUI's platform adapters call for
|
||||
composed operating-system text input.
|
||||
|
||||
The backing buffer stores UTF-8 only at valid scalar boundaries and translates
|
||||
the platform's UTF-16 ranges before mutation. Deterministic tests cover:
|
||||
|
||||
- replacing a selected decomposed `e` plus combining acute accent with `é`;
|
||||
- a marked decomposed-accent IME composition and commit;
|
||||
- inserting, selecting, and replacing the multi-codepoint grapheme `👩🏽💻`;
|
||||
- collapsed selection placement after every replacement.
|
||||
|
||||
These tests prove range conversion and composed-text preservation without
|
||||
splitting UTF-8. An actual keyboard IME on X11/Wayland and macOS remains an
|
||||
end-to-end platform test, not a unit-test claim.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
cargo check --locked
|
||||
cargo test --locked
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
The first command installs the pinned Rust toolchain if rustup does not already
|
||||
have it and downloads Zed's GPUI dependency closure.
|
||||
|
||||
On Ubuntu hosts that have only the runtime libraries, checks work with
|
||||
`RUST_FONTCONFIG_DLOPEN=1`. Linking a test or binary additionally needs the
|
||||
unversioned linker names normally installed by `libxcb1-dev`,
|
||||
`libxkbcommon-dev`, and `libxkbcommon-x11-dev`. The amd-server validation used
|
||||
temporary symlinks under the ignored `target/native-libs` directory and set
|
||||
`LIBRARY_PATH` to that directory; no system packages or root files changed.
|
||||
|
||||
## Observed local cost
|
||||
|
||||
On amd-server, the first successful check required roughly 90 seconds after
|
||||
installing/fetching, with some Cargo-cache contention from concurrent work. The
|
||||
lockfile contains 691 packages. After check, test, Clippy, and a debug build,
|
||||
the isolated target directory was 4.4 GiB and the debug binary was 535 MiB. The
|
||||
minimal Rust 1.97.1 toolchain occupies 624 MiB. These are development costs, not
|
||||
optimized release measurements.
|
||||
@@ -0,0 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.97.1"
|
||||
profile = "minimal"
|
||||
components = ["clippy", "rustfmt"]
|
||||
@@ -0,0 +1,486 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use gpui::{
|
||||
App, Bounds, ClipboardItem, Context, Element, ElementId, ElementInputHandler, Entity,
|
||||
EntityInputHandler, FocusHandle, GlobalElementId, LayoutId, Pixels, Point, Role, Style,
|
||||
UTF16Selection, Window, WindowBounds, WindowOptions, div, prelude::*, px, relative, rgb, size,
|
||||
text,
|
||||
};
|
||||
use gpui_platform::application;
|
||||
|
||||
const WORKSPACE_LABEL: &str = "Lumbridge workspace";
|
||||
const PANE_LABEL: &str = "Local terminal pane";
|
||||
const PANE_DESCRIPTION: &str = "Needs input: choose whether to run the proposed command";
|
||||
const PANE_ACCESSIBILITY_ID: &str = "lumbridge.pane.local-terminal";
|
||||
|
||||
struct AccessibilityProbe {
|
||||
pane_focus: FocusHandle,
|
||||
input: Entity<ImeTextReceiver>,
|
||||
}
|
||||
|
||||
impl AccessibilityProbe {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let pane_focus = cx.focus_handle().tab_stop(true);
|
||||
window.focus(&pane_focus, cx);
|
||||
Self {
|
||||
pane_focus,
|
||||
input: cx.new(|_| ImeTextReceiver::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum BoundaryBias {
|
||||
Floor,
|
||||
Ceil,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
struct TextBuffer {
|
||||
text: String,
|
||||
selection: Range<usize>,
|
||||
marked: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
fn utf16_to_utf8(text: &str, offset: usize, bias: BoundaryBias) -> usize {
|
||||
let mut utf16_offset = 0;
|
||||
|
||||
for (utf8_offset, character) in text.char_indices() {
|
||||
if utf16_offset == offset {
|
||||
return utf8_offset;
|
||||
}
|
||||
|
||||
let next_utf16 = utf16_offset + character.len_utf16();
|
||||
if offset < next_utf16 {
|
||||
return match bias {
|
||||
BoundaryBias::Floor => utf8_offset,
|
||||
BoundaryBias::Ceil => utf8_offset + character.len_utf8(),
|
||||
};
|
||||
}
|
||||
utf16_offset = next_utf16;
|
||||
}
|
||||
|
||||
text.len()
|
||||
}
|
||||
|
||||
fn utf8_to_utf16(text: &str, offset: usize) -> usize {
|
||||
text[..offset].encode_utf16().count()
|
||||
}
|
||||
|
||||
fn range_from_utf16(text: &str, range: &Range<usize>) -> Range<usize> {
|
||||
if range.is_empty() {
|
||||
let offset = Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor);
|
||||
return offset..offset;
|
||||
}
|
||||
|
||||
Self::utf16_to_utf8(text, range.start, BoundaryBias::Floor)
|
||||
..Self::utf16_to_utf8(text, range.end, BoundaryBias::Ceil)
|
||||
}
|
||||
|
||||
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
|
||||
Self::utf8_to_utf16(&self.text, range.start)..Self::utf8_to_utf16(&self.text, range.end)
|
||||
}
|
||||
|
||||
fn selection_utf16(&self) -> Range<usize> {
|
||||
self.range_to_utf16(&self.selection)
|
||||
}
|
||||
|
||||
fn marked_utf16(&self) -> Option<Range<usize>> {
|
||||
self.marked.as_ref().map(|range| self.range_to_utf16(range))
|
||||
}
|
||||
|
||||
fn set_selection_utf16(&mut self, range: Range<usize>) {
|
||||
self.selection = Self::range_from_utf16(&self.text, &range);
|
||||
}
|
||||
|
||||
fn replacement_range(&self, range_utf16: Option<&Range<usize>>) -> Range<usize> {
|
||||
range_utf16
|
||||
.map(|range| Self::range_from_utf16(&self.text, range))
|
||||
.or_else(|| self.marked.clone())
|
||||
.unwrap_or_else(|| self.selection.clone())
|
||||
}
|
||||
|
||||
fn replace_text_in_range(&mut self, range_utf16: Option<Range<usize>>, new_text: &str) {
|
||||
let range = self.replacement_range(range_utf16.as_ref());
|
||||
let caret = range.start + new_text.len();
|
||||
self.text.replace_range(range, new_text);
|
||||
self.selection = caret..caret;
|
||||
self.marked = None;
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selection_utf16: Option<Range<usize>>,
|
||||
) {
|
||||
let range = self.replacement_range(range_utf16.as_ref());
|
||||
let insertion_start = range.start;
|
||||
self.text.replace_range(range, new_text);
|
||||
|
||||
let inserted = insertion_start..insertion_start + new_text.len();
|
||||
self.marked = (!new_text.is_empty()).then(|| inserted.clone());
|
||||
self.selection = new_selection_utf16
|
||||
.map(|selection| Self::range_from_utf16(new_text, &selection))
|
||||
.map(|selection| insertion_start + selection.start..insertion_start + selection.end)
|
||||
.unwrap_or(inserted.end..inserted.end);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct ImeTextReceiver {
|
||||
buffer: TextBuffer,
|
||||
}
|
||||
|
||||
impl EntityInputHandler for ImeTextReceiver {
|
||||
fn text_for_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
adjusted_range: &mut Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<String> {
|
||||
let range = TextBuffer::range_from_utf16(&self.buffer.text, &range_utf16);
|
||||
adjusted_range.replace(self.buffer.range_to_utf16(&range));
|
||||
Some(self.buffer.text[range].to_owned())
|
||||
}
|
||||
|
||||
fn selected_text_range(
|
||||
&mut self,
|
||||
_ignore_disabled_input: bool,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<UTF16Selection> {
|
||||
Some(UTF16Selection {
|
||||
range: self.buffer.selection_utf16(),
|
||||
reversed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn marked_text_range(
|
||||
&self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Range<usize>> {
|
||||
self.buffer.marked_utf16()
|
||||
}
|
||||
|
||||
fn unmark_text(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.buffer.marked = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn paste(&mut self, item: ClipboardItem, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some(text) = item.text() {
|
||||
self.replace_text_in_range(None, &text, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
text: &str,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer.replace_text_in_range(range_utf16, text);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn replace_and_mark_text_in_range(
|
||||
&mut self,
|
||||
range_utf16: Option<Range<usize>>,
|
||||
new_text: &str,
|
||||
new_selected_range_utf16: Option<Range<usize>>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer
|
||||
.replace_and_mark_text_in_range(range_utf16, new_text, new_selected_range_utf16);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn bounds_for_range(
|
||||
&mut self,
|
||||
_range_utf16: Range<usize>,
|
||||
element_bounds: Bounds<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<Bounds<Pixels>> {
|
||||
Some(element_bounds)
|
||||
}
|
||||
|
||||
fn character_index_for_point(
|
||||
&mut self,
|
||||
_point: Point<Pixels>,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
Some(self.buffer.selection_utf16().end)
|
||||
}
|
||||
|
||||
fn set_selected_text_range(
|
||||
&mut self,
|
||||
range_utf16: Range<usize>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.buffer.set_selection_utf16(range_utf16);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn text_length_utf16(
|
||||
&mut self,
|
||||
_window: &mut Window,
|
||||
_cx: &mut Context<Self>,
|
||||
) -> Option<usize> {
|
||||
Some(self.buffer.text.encode_utf16().count())
|
||||
}
|
||||
}
|
||||
|
||||
struct InputCaptureElement {
|
||||
input: Entity<ImeTextReceiver>,
|
||||
focus: FocusHandle,
|
||||
}
|
||||
|
||||
impl IntoElement for InputCaptureElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for InputCaptureElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = ();
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
let mut style = Style::default();
|
||||
style.size.width = relative(1.).into();
|
||||
style.size.height = px(32.).into();
|
||||
(window.request_layout(style, [], cx), ())
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_window: &mut Window,
|
||||
_cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&gpui::InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
_prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
window.handle_input(
|
||||
&self.focus,
|
||||
ElementInputHandler::new(bounds, self.input.clone()),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for AccessibilityProbe {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let input_text = self.input.read(cx).buffer.text.clone();
|
||||
|
||||
div()
|
||||
.id("lumbridge-application")
|
||||
.role(Role::Application)
|
||||
.aria_label("Lumbridge accessibility probe")
|
||||
.size_full()
|
||||
.p_4()
|
||||
.bg(rgb(0x0c0e13))
|
||||
.text_color(rgb(0xd9e2f2))
|
||||
.child(
|
||||
div()
|
||||
.id("workspace")
|
||||
.accessibility_id("lumbridge.workspace.primary")
|
||||
.role(Role::Region)
|
||||
.aria_label(WORKSPACE_LABEL)
|
||||
.size_full()
|
||||
.p_3()
|
||||
.border_1()
|
||||
.border_color(rgb(0x293244))
|
||||
.rounded_md()
|
||||
.child(
|
||||
div()
|
||||
.id("pane-local-terminal")
|
||||
.accessibility_id(PANE_ACCESSIBILITY_ID)
|
||||
.role(Role::Pane)
|
||||
.aria_label(PANE_LABEL)
|
||||
.aria_description(PANE_DESCRIPTION)
|
||||
.aria_selected(true)
|
||||
.focusable()
|
||||
.track_focus(&self.pane_focus)
|
||||
.size_full()
|
||||
.p_3()
|
||||
.bg(rgb(0x121722))
|
||||
.rounded_md()
|
||||
.child(
|
||||
div()
|
||||
.id("terminal-content")
|
||||
.role(Role::Terminal)
|
||||
.aria_label("Terminal output")
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(text!("codex is waiting for approval"))
|
||||
.child(
|
||||
div()
|
||||
.id("ime-input-status")
|
||||
.role(Role::Status)
|
||||
.aria_label("Composed input")
|
||||
.child(if input_text.is_empty() {
|
||||
"Type composed Unicode here…".to_owned()
|
||||
} else {
|
||||
input_text
|
||||
}),
|
||||
)
|
||||
.child(InputCaptureElement {
|
||||
input: self.input.clone(),
|
||||
focus: self.pane_focus.clone(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
application().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(760.0), px(420.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(gpui::TitlebarOptions {
|
||||
title: Some("Lumbridge · GPUI accessibility probe".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|window, cx| cx.new(|cx| AccessibilityProbe::new(window, cx)),
|
||||
)
|
||||
.expect("GPUI accessibility probe window should open");
|
||||
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_and_focused_pane_map_to_an_accesskit_tree() {
|
||||
use gpui::accesskit::{Node, NodeId, Tree, TreeId, TreeUpdate};
|
||||
|
||||
let workspace_id = NodeId(1);
|
||||
let pane_id = NodeId(2);
|
||||
|
||||
let mut workspace = Node::new(Role::Region);
|
||||
workspace.set_label(WORKSPACE_LABEL.to_owned());
|
||||
workspace.set_children(vec![pane_id]);
|
||||
|
||||
let mut pane = Node::new(Role::Pane);
|
||||
pane.set_author_id(PANE_ACCESSIBILITY_ID.to_owned());
|
||||
pane.set_label(PANE_LABEL.to_owned());
|
||||
pane.set_description(PANE_DESCRIPTION.to_owned());
|
||||
pane.set_selected(true);
|
||||
|
||||
let update = TreeUpdate {
|
||||
nodes: vec![(workspace_id, workspace), (pane_id, pane)],
|
||||
tree: Some(Tree::new(workspace_id)),
|
||||
tree_id: TreeId::ROOT,
|
||||
focus: pane_id,
|
||||
};
|
||||
let pane = &update.nodes[1].1;
|
||||
|
||||
assert_eq!(
|
||||
update.tree.as_ref().map(|tree| tree.root),
|
||||
Some(workspace_id)
|
||||
);
|
||||
assert_eq!(update.focus, pane_id);
|
||||
assert_eq!(update.nodes[0].1.role(), Role::Region);
|
||||
assert_eq!(update.nodes[0].1.label(), Some(WORKSPACE_LABEL));
|
||||
assert_eq!(pane.role(), Role::Pane);
|
||||
assert_eq!(pane.author_id(), Some(PANE_ACCESSIBILITY_ID));
|
||||
assert_eq!(pane.label(), Some(PANE_LABEL));
|
||||
assert_eq!(pane.description(), Some(PANE_DESCRIPTION));
|
||||
assert_eq!(pane.is_selected(), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_selected_composed_accent_without_splitting_utf8() {
|
||||
let mut buffer = TextBuffer {
|
||||
text: "Cafe\u{301}".to_owned(),
|
||||
selection: 0..0,
|
||||
marked: None,
|
||||
};
|
||||
buffer.set_selection_utf16(3..5);
|
||||
|
||||
buffer.replace_text_in_range(None, "é");
|
||||
|
||||
assert_eq!(buffer.text, "Café");
|
||||
assert_eq!(buffer.selection_utf16(), 4..4);
|
||||
assert!(buffer.text.is_char_boundary(buffer.selection.start));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composed_ime_and_multi_codepoint_grapheme_preserve_selection() {
|
||||
let grapheme = "👩🏽💻";
|
||||
let grapheme_utf16_len = grapheme.encode_utf16().count();
|
||||
let mut buffer = TextBuffer::default();
|
||||
|
||||
buffer.replace_and_mark_text_in_range(Some(0..0), "e\u{301}", Some(2..2));
|
||||
assert_eq!(buffer.text, "e\u{301}");
|
||||
assert_eq!(buffer.marked_utf16(), Some(0..2));
|
||||
assert_eq!(buffer.selection_utf16(), 2..2);
|
||||
|
||||
buffer.replace_text_in_range(None, "é");
|
||||
assert_eq!(buffer.text, "é");
|
||||
assert_eq!(buffer.marked_utf16(), None);
|
||||
assert_eq!(buffer.selection_utf16(), 1..1);
|
||||
|
||||
buffer.set_selection_utf16(0..1);
|
||||
buffer.replace_text_in_range(None, grapheme);
|
||||
|
||||
assert_eq!(buffer.text, grapheme);
|
||||
assert_eq!(
|
||||
buffer.selection_utf16(),
|
||||
grapheme_utf16_len..grapheme_utf16_len
|
||||
);
|
||||
assert!(buffer.text.is_char_boundary(buffer.selection.start));
|
||||
|
||||
buffer.set_selection_utf16(0..grapheme_utf16_len);
|
||||
buffer.replace_text_in_range(None, "done");
|
||||
assert_eq!(buffer.text, "done");
|
||||
assert_eq!(buffer.selection_utf16(), 4..4);
|
||||
}
|
||||
}
|
||||
+627
-86
@@ -1,85 +1,445 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px,
|
||||
rgb, size,
|
||||
App, Application, Bounds, Context, FocusHandle, KeyBinding, KeyDownEvent, Window, WindowBounds,
|
||||
WindowOptions, actions, div, prelude::*, px, rgb, size,
|
||||
};
|
||||
use lumbridge_spike_model::{
|
||||
FOOTER_CENTER, FOOTER_LEFT, FOOTER_RIGHT, PANES, PaneFixture, WORKSPACES,
|
||||
ActionOutcome, FOOTER_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
|
||||
SurfaceKind, WORKSPACES,
|
||||
};
|
||||
|
||||
const BG: u32 = 0x0c0e13;
|
||||
const PANEL: u32 = 0x121722;
|
||||
const PANEL_ALT: u32 = 0x171d29;
|
||||
const BORDER: u32 = 0x293244;
|
||||
const TEXT: u32 = 0xd9e2f2;
|
||||
const MUTED: u32 = 0x7f8ba3;
|
||||
const ACCENT: u32 = 0x77bdfb;
|
||||
const BG: u32 = 0x090c12;
|
||||
const PANEL: u32 = 0x101620;
|
||||
const PANEL_ALT: u32 = 0x151d29;
|
||||
const PANEL_ACTIVE: u32 = 0x182334;
|
||||
const BORDER: u32 = 0x263246;
|
||||
const BORDER_QUIET: u32 = 0x1c2636;
|
||||
const TEXT: u32 = 0xdbe5f4;
|
||||
const MUTED: u32 = 0x8290a8;
|
||||
const ACCENT: u32 = 0x68b5f8;
|
||||
const ATTENTION: u32 = 0xf1b96a;
|
||||
const SUCCESS: u32 = 0x70d6a8;
|
||||
const TIMING_SAMPLE_LIMIT: usize = 256;
|
||||
|
||||
struct LumbridgeShell;
|
||||
actions!(
|
||||
lumbridge,
|
||||
[
|
||||
FocusLeft,
|
||||
FocusRight,
|
||||
FocusUp,
|
||||
FocusDown,
|
||||
OpenPalette,
|
||||
ClosePalette,
|
||||
SelectPane1,
|
||||
SelectPane2,
|
||||
SelectPane3,
|
||||
SelectPane4,
|
||||
SelectPane5,
|
||||
SelectPane6,
|
||||
]
|
||||
);
|
||||
|
||||
fn pane_card(pane: &PaneFixture) -> impl IntoElement {
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.bg(rgb(PANEL))
|
||||
.border_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.rounded_md()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_3()
|
||||
.h(px(34.0))
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.child(pane.title)
|
||||
.child(div().text_xs().text_color(rgb(ACCENT)).child(pane.badge)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child(pane.target),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_3()
|
||||
.pb_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.children(pane.lines.into_iter()),
|
||||
struct LumbridgeShell {
|
||||
model: ShellModel,
|
||||
timing: RenderTiming,
|
||||
root_focus: FocusHandle,
|
||||
pane_focus: [FocusHandle; 6],
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RenderTiming {
|
||||
pending_dispatch: Option<Instant>,
|
||||
dispatch_to_element_micros: VecDeque<u128>,
|
||||
}
|
||||
|
||||
impl RenderTiming {
|
||||
fn mark_dispatch(&mut self) {
|
||||
self.pending_dispatch = Some(Instant::now());
|
||||
}
|
||||
|
||||
fn observe_element_build(&mut self) {
|
||||
let Some(started) = self.pending_dispatch.take() else {
|
||||
return;
|
||||
};
|
||||
if self.dispatch_to_element_micros.len() == TIMING_SAMPLE_LIMIT {
|
||||
self.dispatch_to_element_micros.pop_front();
|
||||
}
|
||||
self.dispatch_to_element_micros
|
||||
.push_back(started.elapsed().as_micros());
|
||||
}
|
||||
|
||||
fn summary(&self) -> String {
|
||||
if self.dispatch_to_element_micros.is_empty() {
|
||||
return "dispatch→element collecting…".to_owned();
|
||||
}
|
||||
let mut samples = self
|
||||
.dispatch_to_element_micros
|
||||
.iter()
|
||||
.copied()
|
||||
.collect::<Vec<_>>();
|
||||
samples.sort_unstable();
|
||||
let percentile = |percent: usize| {
|
||||
let index = ((samples.len() - 1) * percent) / 100;
|
||||
samples[index]
|
||||
};
|
||||
format!(
|
||||
"dispatch→element p50 {}µs · p95 {}µs · n{}",
|
||||
percentile(50),
|
||||
percentile(95),
|
||||
samples.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl LumbridgeShell {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let pane_focus = std::array::from_fn(|index| {
|
||||
cx.focus_handle()
|
||||
.tab_index(index as isize + 1)
|
||||
.tab_stop(true)
|
||||
});
|
||||
let root_focus = cx.focus_handle();
|
||||
window.focus(&pane_focus[0]);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(650))
|
||||
.await;
|
||||
if this
|
||||
.update(cx, |shell, cx| {
|
||||
shell.dispatch(ShellAction::SyntheticStreamTick);
|
||||
cx.notify();
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
Self {
|
||||
model: ShellModel::default(),
|
||||
timing: RenderTiming::default(),
|
||||
root_focus,
|
||||
pane_focus,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||||
self.timing.mark_dispatch();
|
||||
self.model.dispatch(action)
|
||||
}
|
||||
|
||||
fn select_pane(&mut self, pane: PaneId, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dispatch(ShellAction::SelectPane(pane));
|
||||
window.focus(&self.pane_focus[pane.index()]);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn move_focus(
|
||||
&mut self,
|
||||
direction: FocusDirection,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let outcome = self.dispatch(ShellAction::MoveFocus(direction));
|
||||
window.focus(&self.pane_focus[outcome.selected_pane.index()]);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn focus_left(&mut self, _: &FocusLeft, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_focus(FocusDirection::Left, window, cx);
|
||||
}
|
||||
|
||||
fn focus_right(&mut self, _: &FocusRight, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_focus(FocusDirection::Right, window, cx);
|
||||
}
|
||||
|
||||
fn focus_up(&mut self, _: &FocusUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_focus(FocusDirection::Up, window, cx);
|
||||
}
|
||||
|
||||
fn focus_down(&mut self, _: &FocusDown, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.move_focus(FocusDirection::Down, window, cx);
|
||||
}
|
||||
|
||||
fn open_palette(&mut self, _: &OpenPalette, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dispatch(ShellAction::OpenCommandPalette);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn close_palette(&mut self, _: &ClosePalette, _: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dispatch(ShellAction::CloseCommandPalette);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if !self.model.command_palette().is_open() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut query = self.model.command_palette().query().to_owned();
|
||||
match event.keystroke.key.as_str() {
|
||||
"backspace" => {
|
||||
query.pop();
|
||||
}
|
||||
"enter" => {
|
||||
self.dispatch(ShellAction::CloseCommandPalette);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
if let Some(character) = event.keystroke.key_char.as_deref()
|
||||
&& !event.keystroke.modifiers.control
|
||||
&& !event.keystroke.modifiers.platform
|
||||
{
|
||||
query.push_str(character);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dispatch(ShellAction::SetCommandPaletteQuery(query));
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn pane_card(
|
||||
pane: &PaneState,
|
||||
selected: bool,
|
||||
focus: FocusHandle,
|
||||
cx: &mut Context<Self>,
|
||||
) -> gpui::AnyElement {
|
||||
let id = pane.id();
|
||||
let needs_input = pane.needs_input();
|
||||
let label = match pane.kind() {
|
||||
SurfaceKind::Terminal => pane.status().label(),
|
||||
_ => pane.fixture().badge,
|
||||
};
|
||||
let state_color = if needs_input {
|
||||
ATTENTION
|
||||
} else if matches!(pane.kind(), SurfaceKind::Terminal) {
|
||||
SUCCESS
|
||||
} else {
|
||||
ACCENT
|
||||
};
|
||||
let line_start = pane.lines().len().saturating_sub(12);
|
||||
|
||||
div()
|
||||
.id(("pane", id.index()))
|
||||
.track_focus(&focus)
|
||||
.tab_index(id.index() as isize + 1)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL }))
|
||||
.border_1()
|
||||
.border_color(rgb(if selected || needs_input {
|
||||
state_color
|
||||
} else {
|
||||
BORDER
|
||||
}))
|
||||
.rounded(px(5.0))
|
||||
.when(selected, |view| view.border_2())
|
||||
.on_click(cx.listener(move |shell, _, window, cx| {
|
||||
shell.select_pane(id, window, cx);
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_3()
|
||||
.h(px(36.0))
|
||||
.flex_none()
|
||||
.bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT }))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child(format!("{}", id.index() + 1)),
|
||||
)
|
||||
.child(pane.fixture().title),
|
||||
)
|
||||
.child(div().text_xs().text_color(rgb(state_color)).child(label)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child(pane.fixture().target)
|
||||
.when(selected, |view| view.child("FOCUSED")),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.px_3()
|
||||
.pb_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.children(pane.lines()[line_start..].iter().cloned()),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn command_palette(&self) -> impl IntoElement {
|
||||
let query = self.model.command_palette().query();
|
||||
let prompt = if query.is_empty() {
|
||||
"Type a command…".to_owned()
|
||||
} else {
|
||||
query.to_owned()
|
||||
};
|
||||
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_start()
|
||||
.pt(px(92.0))
|
||||
.bg(gpui::black().opacity(0.72))
|
||||
.child(
|
||||
div()
|
||||
.w(px(620.0))
|
||||
.overflow_hidden()
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_1()
|
||||
.border_color(rgb(ACCENT))
|
||||
.rounded(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.text_color(rgb(if query.is_empty() { MUTED } else { TEXT }))
|
||||
.child(prompt),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.p_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(px(5.0))
|
||||
.bg(rgb(PANEL_ACTIVE))
|
||||
.child("Focus next pane")
|
||||
.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Workspace · navigation"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Open attention request"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Share selected pane to Buzz…"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Enter to run")
|
||||
.child("Esc to close"),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for LumbridgeShell {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let attention = self.model.pane(PaneId::ClaudeUi);
|
||||
let sidebar = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.w(px(220.0))
|
||||
.w(px(248.0))
|
||||
.flex_none()
|
||||
.bg(rgb(PANEL))
|
||||
.border_r_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_3()
|
||||
.text_sm()
|
||||
.pt_4()
|
||||
.pb_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(ATTENTION))
|
||||
.child("ATTENTION · 1"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mx_2()
|
||||
.mb_3()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded(px(5.0))
|
||||
.bg(rgb(PANEL_ACTIVE))
|
||||
.border_1()
|
||||
.border_color(rgb(ATTENTION))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(TEXT))
|
||||
.child(attention.fixture().title),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Waiting for a split decision"),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("WORKSPACES"),
|
||||
.child("WORKTREES"),
|
||||
)
|
||||
.children(WORKSPACES.into_iter().enumerate().map(|(index, name)| {
|
||||
div()
|
||||
@@ -87,30 +447,130 @@ impl Render for LumbridgeShell {
|
||||
.mb_1()
|
||||
.px_3()
|
||||
.py_2()
|
||||
.rounded_md()
|
||||
.rounded(px(5.0))
|
||||
.when(index == 0, |view| {
|
||||
view.bg(rgb(PANEL_ALT)).text_color(rgb(TEXT))
|
||||
})
|
||||
.when(index != 0, |view| view.text_color(rgb(MUTED)))
|
||||
.child(name)
|
||||
.when(index == 0, |view| {
|
||||
view.child(
|
||||
div()
|
||||
.mt_1()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("main · metal"),
|
||||
)
|
||||
})
|
||||
}))
|
||||
.child(
|
||||
div()
|
||||
.mt_4()
|
||||
.mt_3()
|
||||
.px_4()
|
||||
.py_2()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("HOSTS")
|
||||
.child("RUNTIMES"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(SUCCESS))
|
||||
.child("metal · connected"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("amd-server · connected"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_4()
|
||||
.py_1()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("spark-1 · sleeping"),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.m_3()
|
||||
.p_3()
|
||||
.rounded(px(5.0))
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Buzz · lumbridgecode")
|
||||
.child(
|
||||
div()
|
||||
.mt_2()
|
||||
.text_color(rgb(ACCENT))
|
||||
.child("● metal · connected"),
|
||||
)
|
||||
.child(div().mt_2().child("● amd-server · connected"))
|
||||
.child(div().mt_2().child("○ spark-1 · sleeping")),
|
||||
.mt_1()
|
||||
.text_color(rgb(SUCCESS))
|
||||
.child("connected · signed identity"),
|
||||
),
|
||||
);
|
||||
|
||||
let tabs = div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(px(38.0))
|
||||
.flex_none()
|
||||
.px_2()
|
||||
.gap_1()
|
||||
.bg(rgb(PANEL))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(
|
||||
div()
|
||||
.h_full()
|
||||
.flex()
|
||||
.items_center()
|
||||
.px_3()
|
||||
.border_b_2()
|
||||
.border_color(rgb(ACCENT))
|
||||
.text_sm()
|
||||
.child("Agent workspace"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Architecture.md"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("Review"),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.px_3()
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("6 surfaces · 3 remote · 1 waiting"),
|
||||
);
|
||||
|
||||
let pane_cards = self
|
||||
.model
|
||||
.panes()
|
||||
.iter()
|
||||
.map(|pane| {
|
||||
Self::pane_card(
|
||||
pane,
|
||||
self.model.selected_pane() == pane.id(),
|
||||
self.pane_focus[pane.id().index()].clone(),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let grid = div()
|
||||
.grid()
|
||||
.grid_cols(3)
|
||||
@@ -118,9 +578,48 @@ impl Render for LumbridgeShell {
|
||||
.gap_2()
|
||||
.p_2()
|
||||
.size_full()
|
||||
.children(PANES.iter().map(pane_card));
|
||||
.children(pane_cards);
|
||||
|
||||
div()
|
||||
let counters = self.model.counters();
|
||||
let footer_left = format!(
|
||||
"rev {} · {} focus moves · {} surface updates · {} lines",
|
||||
self.model.revision(),
|
||||
counters.focus_moves,
|
||||
counters.surface_updates,
|
||||
counters.terminal_lines_appended
|
||||
);
|
||||
let timing = self.timing.summary();
|
||||
|
||||
let root = div()
|
||||
.id("lumbridge-shell")
|
||||
.relative()
|
||||
.track_focus(&self.root_focus)
|
||||
.key_context("LumbridgeShell")
|
||||
.on_action(cx.listener(Self::focus_left))
|
||||
.on_action(cx.listener(Self::focus_right))
|
||||
.on_action(cx.listener(Self::focus_up))
|
||||
.on_action(cx.listener(Self::focus_down))
|
||||
.on_action(cx.listener(Self::open_palette))
|
||||
.on_action(cx.listener(Self::close_palette))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane1, window, cx| {
|
||||
shell.select_pane(PaneId::CodexRuntime, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane2, window, cx| {
|
||||
shell.select_pane(PaneId::ClaudeUi, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane3, window, cx| {
|
||||
shell.select_pane(PaneId::PiDocs, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane4, window, cx| {
|
||||
shell.select_pane(PaneId::Architecture, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane5, window, cx| {
|
||||
shell.select_pane(PaneId::AcpPreview, window, cx);
|
||||
}))
|
||||
.on_action(cx.listener(|shell, _: &SelectPane6, window, cx| {
|
||||
shell.select_pane(PaneId::RuntimeReview, window, cx);
|
||||
}))
|
||||
.on_key_down(cx.listener(Self::on_key_down))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.size_full()
|
||||
@@ -130,63 +629,105 @@ impl Render for LumbridgeShell {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(px(46.0))
|
||||
.h(px(48.0))
|
||||
.flex_none()
|
||||
.px_4()
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_b_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.child(div().text_lg().child("Lumbridge"))
|
||||
.child(
|
||||
div()
|
||||
.ml_3()
|
||||
.text_sm()
|
||||
.text_color(rgb(MUTED))
|
||||
.child("UI spike · GPUI"),
|
||||
.child("Lumbridge Code / main"),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
div()
|
||||
.mr_4()
|
||||
.text_xs()
|
||||
.text_color(rgb(SUCCESS))
|
||||
.child("metal · runtime online"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.text_color(rgb(ACCENT))
|
||||
.child("⌘K Command Palette"),
|
||||
.child("Ctrl/⌘ K · Commands"),
|
||||
),
|
||||
)
|
||||
.child(div().flex().flex_1().min_h_0().child(sidebar).child(grid))
|
||||
.child(
|
||||
div().flex().flex_1().min_h_0().child(sidebar).child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(tabs)
|
||||
.child(grid),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.h(px(30.0))
|
||||
.h(px(32.0))
|
||||
.flex_none()
|
||||
.px_3()
|
||||
.bg(rgb(PANEL_ALT))
|
||||
.border_t_1()
|
||||
.border_color(rgb(BORDER))
|
||||
.border_color(rgb(BORDER_QUIET))
|
||||
.text_xs()
|
||||
.text_color(rgb(MUTED))
|
||||
.child(FOOTER_LEFT)
|
||||
.child(FOOTER_CENTER)
|
||||
.child(footer_left)
|
||||
.child(timing)
|
||||
.child(FOOTER_RIGHT),
|
||||
)
|
||||
.when(self.model.command_palette().is_open(), |view| {
|
||||
view.child(self.command_palette())
|
||||
});
|
||||
self.timing.observe_element_build();
|
||||
root
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx);
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("left", FocusLeft, Some("LumbridgeShell")),
|
||||
KeyBinding::new("right", FocusRight, Some("LumbridgeShell")),
|
||||
KeyBinding::new("up", FocusUp, Some("LumbridgeShell")),
|
||||
KeyBinding::new("down", FocusDown, Some("LumbridgeShell")),
|
||||
KeyBinding::new("h", FocusLeft, Some("LumbridgeShell")),
|
||||
KeyBinding::new("l", FocusRight, Some("LumbridgeShell")),
|
||||
KeyBinding::new("k", FocusUp, Some("LumbridgeShell")),
|
||||
KeyBinding::new("j", FocusDown, Some("LumbridgeShell")),
|
||||
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
|
||||
KeyBinding::new("escape", ClosePalette, Some("LumbridgeShell")),
|
||||
KeyBinding::new("1", SelectPane1, Some("LumbridgeShell")),
|
||||
KeyBinding::new("2", SelectPane2, Some("LumbridgeShell")),
|
||||
KeyBinding::new("3", SelectPane3, Some("LumbridgeShell")),
|
||||
KeyBinding::new("4", SelectPane4, Some("LumbridgeShell")),
|
||||
KeyBinding::new("5", SelectPane5, Some("LumbridgeShell")),
|
||||
KeyBinding::new("6", SelectPane6, Some("LumbridgeShell")),
|
||||
]);
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||
cx.open_window(
|
||||
WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(gpui::TitlebarOptions {
|
||||
title: Some("Lumbridge · GPUI spike".into()),
|
||||
title: Some("Lumbridge · GPUI workspace".into()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
|_, cx| cx.new(|_| LumbridgeShell),
|
||||
|window, cx| cx.new(|cx| LumbridgeShell::new(window, cx)),
|
||||
)
|
||||
.expect("GPUI spike window should open");
|
||||
.expect("GPUI workspace window should open");
|
||||
cx.activate(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,70 @@
|
||||
//! Deterministic state and fixtures shared by the GPUI and Floem spikes.
|
||||
//!
|
||||
//! UI candidates translate framework input into [`ShellAction`] and render the
|
||||
//! resulting [`ShellModel`]. This makes their interaction traces comparable.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
pub const GRID_ROWS: usize = 2;
|
||||
pub const GRID_COLUMNS: usize = 3;
|
||||
pub const DEFAULT_TERMINAL_LINE_LIMIT: usize = 64;
|
||||
|
||||
/// Stable identity used in traces and accessibility identifiers.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
#[repr(u8)]
|
||||
pub enum PaneId {
|
||||
CodexRuntime = 0,
|
||||
ClaudeUi = 1,
|
||||
PiDocs = 2,
|
||||
Architecture = 3,
|
||||
AcpPreview = 4,
|
||||
RuntimeReview = 5,
|
||||
}
|
||||
|
||||
impl PaneId {
|
||||
pub const ALL: [Self; GRID_ROWS * GRID_COLUMNS] = [
|
||||
Self::CodexRuntime,
|
||||
Self::ClaudeUi,
|
||||
Self::PiDocs,
|
||||
Self::Architecture,
|
||||
Self::AcpPreview,
|
||||
Self::RuntimeReview,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub const fn index(self) -> usize {
|
||||
self as usize
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn stable_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::CodexRuntime => "codex-runtime",
|
||||
Self::ClaudeUi => "claude-ui",
|
||||
Self::PiDocs => "pi-docs",
|
||||
Self::Architecture => "architecture",
|
||||
Self::AcpPreview => "acp-preview",
|
||||
Self::RuntimeReview => "runtime-review",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn row(self) -> usize {
|
||||
self.index() / GRID_COLUMNS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn column(self) -> usize {
|
||||
self.index() % GRID_COLUMNS
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for PaneId {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.stable_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SurfaceKind {
|
||||
Terminal,
|
||||
@@ -6,21 +73,45 @@ pub enum SurfaceKind {
|
||||
Review,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PaneStatus {
|
||||
Working,
|
||||
NeedsInput,
|
||||
Streaming,
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl PaneStatus {
|
||||
#[must_use]
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Working => "WORKING",
|
||||
Self::NeedsInput => "NEEDS INPUT",
|
||||
Self::Streaming => "STREAMING",
|
||||
Self::Ready => "READY",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PaneFixture {
|
||||
pub id: PaneId,
|
||||
pub title: &'static str,
|
||||
pub badge: &'static str,
|
||||
pub target: &'static str,
|
||||
pub kind: SurfaceKind,
|
||||
pub status: PaneStatus,
|
||||
pub lines: [&'static str; 4],
|
||||
}
|
||||
|
||||
pub const PANES: [PaneFixture; 6] = [
|
||||
PaneFixture {
|
||||
id: PaneId::CodexRuntime,
|
||||
title: "Codex · runtime",
|
||||
badge: "WORKING",
|
||||
target: "metal · Tailscale SSH",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::Working,
|
||||
lines: [
|
||||
"$ cargo nextest run -p lumbridge-runtime",
|
||||
"PASS remote::reconnect_replays_output",
|
||||
@@ -29,10 +120,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::ClaudeUi,
|
||||
title: "Claude Code · UI",
|
||||
badge: "NEEDS INPUT",
|
||||
target: "MacBook Air · local",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::NeedsInput,
|
||||
lines: [
|
||||
"$ bacon clippy",
|
||||
"finished in 0.42s",
|
||||
@@ -41,10 +134,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::PiDocs,
|
||||
title: "Pi · docs",
|
||||
badge: "STREAMING",
|
||||
target: "amd-server · OpenSSH",
|
||||
kind: SurfaceKind::Terminal,
|
||||
status: PaneStatus::Streaming,
|
||||
lines: [
|
||||
"$ cargo watch --why",
|
||||
"ACP session resumed at event 1842",
|
||||
@@ -53,10 +148,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::Architecture,
|
||||
title: "Architecture.md",
|
||||
badge: "MARKDOWN",
|
||||
target: "lumbridge-code · worktree",
|
||||
kind: SurfaceKind::Markdown,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"## Remote session path",
|
||||
"The destination runtime owns the PTY.",
|
||||
@@ -65,10 +162,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::AcpPreview,
|
||||
title: "Preview · ACP docs",
|
||||
badge: "BROWSER",
|
||||
target: "isolated system web engine",
|
||||
kind: SurfaceKind::Browser,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"https://agentclientprotocol.com",
|
||||
"Content process: sandboxed",
|
||||
@@ -77,10 +176,12 @@ pub const PANES: [PaneFixture; 6] = [
|
||||
],
|
||||
},
|
||||
PaneFixture {
|
||||
id: PaneId::RuntimeReview,
|
||||
title: "Changes · lumbridge-runtime",
|
||||
badge: "REVIEW",
|
||||
target: "metal · worktree remote-runtime",
|
||||
kind: SurfaceKind::Review,
|
||||
status: PaneStatus::Ready,
|
||||
lines: [
|
||||
"+ 184 remote transport",
|
||||
"+ 96 SQLite migrations",
|
||||
@@ -97,26 +198,538 @@ pub const WORKSPACES: [&str; 5] = [
|
||||
"ACP adapters",
|
||||
"Usage telemetry",
|
||||
];
|
||||
|
||||
pub const FOOTER_LEFT: &str = "6 panes · 3 remote · 1 needs input";
|
||||
pub const FOOTER_CENTER: &str = "Codex · ChatGPT subscription · 62% window remaining";
|
||||
pub const FOOTER_RIGHT: &str = "burn 8.4%/hr · resets in 2h 14m";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct PaneState {
|
||||
fixture: PaneFixture,
|
||||
status: PaneStatus,
|
||||
status_before_input: Option<PaneStatus>,
|
||||
lines: Vec<String>,
|
||||
synthetic_line_sequence: u64,
|
||||
}
|
||||
|
||||
impl PaneState {
|
||||
#[must_use]
|
||||
pub const fn id(&self) -> PaneId {
|
||||
self.fixture.id
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn fixture(&self) -> &PaneFixture {
|
||||
&self.fixture
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> SurfaceKind {
|
||||
self.fixture.kind
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn status(&self) -> PaneStatus {
|
||||
self.status
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn needs_input(&self) -> bool {
|
||||
matches!(self.status, PaneStatus::NeedsInput)
|
||||
}
|
||||
#[must_use]
|
||||
pub fn lines(&self) -> &[String] {
|
||||
&self.lines
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn synthetic_line_sequence(&self) -> u64 {
|
||||
self.synthetic_line_sequence
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct CommandPaletteState {
|
||||
open: bool,
|
||||
query: String,
|
||||
}
|
||||
|
||||
impl CommandPaletteState {
|
||||
#[must_use]
|
||||
pub const fn is_open(&self) -> bool {
|
||||
self.open
|
||||
}
|
||||
#[must_use]
|
||||
pub fn query(&self) -> &str {
|
||||
&self.query
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FocusDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum ShellAction {
|
||||
MoveFocus(FocusDirection),
|
||||
SelectPane(PaneId),
|
||||
SetNeedsInput { pane: PaneId, needs_input: bool },
|
||||
OpenCommandPalette,
|
||||
CloseCommandPalette,
|
||||
SetCommandPaletteQuery(String),
|
||||
SyntheticStreamTick,
|
||||
}
|
||||
|
||||
/// Counters prove both candidates replayed the same workload. Framework
|
||||
/// adapters may use the event sequence and revision for explicitly labelled
|
||||
/// timing stages; these counters do not imply pixels reached the display.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct MeasurementCounters {
|
||||
pub events_dispatched: u64,
|
||||
pub state_changes: u64,
|
||||
pub focus_moves: u64,
|
||||
pub blocked_focus_moves: u64,
|
||||
pub direct_selections: u64,
|
||||
pub needs_input_changes: u64,
|
||||
pub palette_changes: u64,
|
||||
pub synthetic_ticks: u64,
|
||||
/// Total surfaces invalidated by the deterministic six-surface workload.
|
||||
pub surface_updates: u64,
|
||||
pub terminal_lines_appended: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct ActionOutcome {
|
||||
pub event_sequence: u64,
|
||||
pub revision: u64,
|
||||
pub changed: bool,
|
||||
pub selected_pane: PaneId,
|
||||
pub terminal_lines_appended: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct InvalidTerminalLineLimit;
|
||||
|
||||
impl fmt::Display for InvalidTerminalLineLimit {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("terminal line limit must be greater than zero")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for InvalidTerminalLineLimit {}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ShellModel {
|
||||
panes: [PaneState; GRID_ROWS * GRID_COLUMNS],
|
||||
selected_pane: PaneId,
|
||||
command_palette: CommandPaletteState,
|
||||
terminal_line_limit: usize,
|
||||
synthetic_tick: u64,
|
||||
revision: u64,
|
||||
counters: MeasurementCounters,
|
||||
}
|
||||
|
||||
impl Default for ShellModel {
|
||||
fn default() -> Self {
|
||||
Self::with_terminal_line_limit(DEFAULT_TERMINAL_LINE_LIMIT)
|
||||
.expect("the default terminal line limit is non-zero")
|
||||
}
|
||||
}
|
||||
|
||||
impl ShellModel {
|
||||
pub fn with_terminal_line_limit(limit: usize) -> Result<Self, InvalidTerminalLineLimit> {
|
||||
if limit == 0 {
|
||||
return Err(InvalidTerminalLineLimit);
|
||||
}
|
||||
let panes = PANES.map(|fixture| {
|
||||
let mut lines = fixture.lines.map(String::from).to_vec();
|
||||
if fixture.kind == SurfaceKind::Terminal && lines.len() > limit {
|
||||
lines.drain(..lines.len() - limit);
|
||||
}
|
||||
PaneState {
|
||||
fixture,
|
||||
status: fixture.status,
|
||||
status_before_input: (fixture.status == PaneStatus::NeedsInput)
|
||||
.then_some(PaneStatus::Working),
|
||||
lines,
|
||||
synthetic_line_sequence: 0,
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
panes,
|
||||
selected_pane: PaneId::CodexRuntime,
|
||||
command_palette: CommandPaletteState::default(),
|
||||
terminal_line_limit: limit,
|
||||
synthetic_tick: 0,
|
||||
revision: 0,
|
||||
counters: MeasurementCounters::default(),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn panes(&self) -> &[PaneState; GRID_ROWS * GRID_COLUMNS] {
|
||||
&self.panes
|
||||
}
|
||||
#[must_use]
|
||||
pub fn pane(&self, id: PaneId) -> &PaneState {
|
||||
&self.panes[id.index()]
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn selected_pane(&self) -> PaneId {
|
||||
self.selected_pane
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn command_palette(&self) -> &CommandPaletteState {
|
||||
&self.command_palette
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn terminal_line_limit(&self) -> usize {
|
||||
self.terminal_line_limit
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn synthetic_tick(&self) -> u64 {
|
||||
self.synthetic_tick
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
#[must_use]
|
||||
pub const fn counters(&self) -> MeasurementCounters {
|
||||
self.counters
|
||||
}
|
||||
|
||||
pub fn dispatch(&mut self, action: ShellAction) -> ActionOutcome {
|
||||
self.counters.events_dispatched += 1;
|
||||
let mut changed = false;
|
||||
let mut appended = 0;
|
||||
match action {
|
||||
ShellAction::MoveFocus(direction) => {
|
||||
if let Some(next) = focus_neighbor(self.selected_pane, direction) {
|
||||
self.selected_pane = next;
|
||||
self.counters.focus_moves += 1;
|
||||
changed = true;
|
||||
} else {
|
||||
self.counters.blocked_focus_moves += 1;
|
||||
}
|
||||
}
|
||||
ShellAction::SelectPane(pane) => {
|
||||
if self.selected_pane != pane {
|
||||
self.selected_pane = pane;
|
||||
self.counters.direct_selections += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::SetNeedsInput { pane, needs_input } => {
|
||||
let pane = &mut self.panes[pane.index()];
|
||||
if needs_input && !pane.needs_input() {
|
||||
pane.status_before_input = Some(pane.status);
|
||||
pane.status = PaneStatus::NeedsInput;
|
||||
self.counters.needs_input_changes += 1;
|
||||
changed = true;
|
||||
} else if !needs_input && pane.needs_input() {
|
||||
pane.status = pane.status_before_input.take().unwrap_or(PaneStatus::Ready);
|
||||
self.counters.needs_input_changes += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::OpenCommandPalette => {
|
||||
if !self.command_palette.open {
|
||||
self.command_palette.open = true;
|
||||
self.command_palette.query.clear();
|
||||
self.counters.palette_changes += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::CloseCommandPalette => {
|
||||
if self.command_palette.open {
|
||||
self.command_palette.open = false;
|
||||
self.command_palette.query.clear();
|
||||
self.counters.palette_changes += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::SetCommandPaletteQuery(query) => {
|
||||
if self.command_palette.open && self.command_palette.query != query {
|
||||
self.command_palette.query = query;
|
||||
self.counters.palette_changes += 1;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
ShellAction::SyntheticStreamTick => {
|
||||
self.synthetic_tick += 1;
|
||||
self.counters.synthetic_ticks += 1;
|
||||
for pane in &mut self.panes {
|
||||
pane.synthetic_line_sequence += 1;
|
||||
if pane.kind() != SurfaceKind::Terminal {
|
||||
continue;
|
||||
}
|
||||
pane.lines.push(format!(
|
||||
"[tick {:06}] {} · synthetic line {:06}",
|
||||
self.synthetic_tick,
|
||||
pane.id(),
|
||||
pane.synthetic_line_sequence
|
||||
));
|
||||
if pane.lines.len() > self.terminal_line_limit {
|
||||
pane.lines.remove(0);
|
||||
}
|
||||
appended += 1;
|
||||
}
|
||||
self.counters.surface_updates += self.panes.len() as u64;
|
||||
self.counters.terminal_lines_appended += appended as u64;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.revision += 1;
|
||||
self.counters.state_changes += 1;
|
||||
}
|
||||
ActionOutcome {
|
||||
event_sequence: self.counters.events_dispatched,
|
||||
revision: self.revision,
|
||||
changed,
|
||||
selected_pane: self.selected_pane,
|
||||
terminal_lines_appended: appended,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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),
|
||||
_ => None,
|
||||
};
|
||||
match next {
|
||||
Some(index) => Some(PaneId::ALL[index]),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PANES, SurfaceKind};
|
||||
use super::{
|
||||
FocusDirection, GRID_COLUMNS, GRID_ROWS, PANES, PaneId, PaneStatus, ShellAction,
|
||||
ShellModel, SurfaceKind, focus_neighbor,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn comparison_fixture_has_six_mixed_surfaces() {
|
||||
assert_eq!(PANES.len(), 6);
|
||||
fn fixture_has_stable_ids_and_mixed_surfaces() {
|
||||
assert_eq!(PANES.len(), GRID_ROWS * GRID_COLUMNS);
|
||||
assert_eq!(PANES.map(|pane| pane.id), PaneId::ALL);
|
||||
assert_eq!(PaneId::CodexRuntime.stable_name(), "codex-runtime");
|
||||
assert_eq!(PaneId::RuntimeReview.to_string(), "runtime-review");
|
||||
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Markdown));
|
||||
assert!(PANES.iter().any(|pane| pane.kind == SurfaceKind::Browser));
|
||||
assert_eq!(
|
||||
PANES
|
||||
.iter()
|
||||
.filter(|pane| pane.kind == SurfaceKind::Terminal)
|
||||
.filter(|p| p.kind == SurfaceKind::Terminal)
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_state_has_one_explicit_needs_input_pane() {
|
||||
let model = ShellModel::default();
|
||||
assert_eq!(model.selected_pane(), PaneId::CodexRuntime);
|
||||
assert_eq!(model.revision(), 0);
|
||||
assert!(!model.command_palette().is_open());
|
||||
assert_eq!(
|
||||
model
|
||||
.panes()
|
||||
.iter()
|
||||
.filter(|pane| pane.needs_input())
|
||||
.map(|pane| pane.id())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![PaneId::ClaudeUi]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neighbors_match_two_by_three_grid_without_wrapping() {
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Right),
|
||||
Some(PaneId::ClaudeUi)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::ClaudeUi, FocusDirection::Down),
|
||||
Some(PaneId::AcpPreview)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::RuntimeReview, FocusDirection::Up),
|
||||
Some(PaneId::PiDocs)
|
||||
);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::Architecture, FocusDirection::Left),
|
||||
None
|
||||
);
|
||||
assert_eq!(focus_neighbor(PaneId::PiDocs, FocusDirection::Right), None);
|
||||
assert_eq!(
|
||||
focus_neighbor(PaneId::CodexRuntime, FocusDirection::Up),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focus_updates_selection_revision_and_blocked_counter() {
|
||||
let mut model = ShellModel::default();
|
||||
let blocked = model.dispatch(ShellAction::MoveFocus(FocusDirection::Left));
|
||||
assert!(!blocked.changed);
|
||||
assert_eq!((blocked.event_sequence, blocked.revision), (1, 0));
|
||||
let moved = model.dispatch(ShellAction::MoveFocus(FocusDirection::Right));
|
||||
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);
|
||||
assert_eq!(model.counters().focus_moves, 2);
|
||||
assert_eq!(model.counters().blocked_focus_moves, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_selection_is_idempotent_and_counted_separately() {
|
||||
let mut model = ShellModel::default();
|
||||
assert!(
|
||||
!model
|
||||
.dispatch(ShellAction::SelectPane(PaneId::CodexRuntime))
|
||||
.changed
|
||||
);
|
||||
assert!(
|
||||
model
|
||||
.dispatch(ShellAction::SelectPane(PaneId::RuntimeReview))
|
||||
.changed
|
||||
);
|
||||
assert_eq!(model.selected_pane(), PaneId::RuntimeReview);
|
||||
assert_eq!(model.counters().direct_selections, 1);
|
||||
assert_eq!(model.counters().events_dispatched, 2);
|
||||
assert_eq!(model.counters().state_changes, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_input_restores_previous_status() {
|
||||
let mut model = ShellModel::default();
|
||||
model.dispatch(ShellAction::SetNeedsInput {
|
||||
pane: PaneId::PiDocs,
|
||||
needs_input: true,
|
||||
});
|
||||
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::NeedsInput);
|
||||
assert!(
|
||||
!model
|
||||
.dispatch(ShellAction::SetNeedsInput {
|
||||
pane: PaneId::PiDocs,
|
||||
needs_input: true,
|
||||
})
|
||||
.changed
|
||||
);
|
||||
model.dispatch(ShellAction::SetNeedsInput {
|
||||
pane: PaneId::PiDocs,
|
||||
needs_input: false,
|
||||
});
|
||||
assert_eq!(model.pane(PaneId::PiDocs).status(), PaneStatus::Streaming);
|
||||
model.dispatch(ShellAction::SetNeedsInput {
|
||||
pane: PaneId::ClaudeUi,
|
||||
needs_input: false,
|
||||
});
|
||||
assert_eq!(model.pane(PaneId::ClaudeUi).status(), PaneStatus::Working);
|
||||
assert_eq!(model.counters().needs_input_changes, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_palette_has_explicit_lifecycle() {
|
||||
let mut model = ShellModel::default();
|
||||
assert!(
|
||||
!model
|
||||
.dispatch(ShellAction::SetCommandPaletteQuery("pane".into()))
|
||||
.changed
|
||||
);
|
||||
model.dispatch(ShellAction::OpenCommandPalette);
|
||||
model.dispatch(ShellAction::SetCommandPaletteQuery("pane: next".into()));
|
||||
assert!(model.command_palette().is_open());
|
||||
assert_eq!(model.command_palette().query(), "pane: next");
|
||||
model.dispatch(ShellAction::CloseCommandPalette);
|
||||
assert!(!model.command_palette().is_open());
|
||||
assert_eq!(model.command_palette().query(), "");
|
||||
assert_eq!(model.counters().palette_changes, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_append_predictable_lines_to_terminals_only() {
|
||||
let mut model = ShellModel::default();
|
||||
let markdown = model.pane(PaneId::Architecture).lines().to_vec();
|
||||
let outcome = model.dispatch(ShellAction::SyntheticStreamTick);
|
||||
assert_eq!(outcome.terminal_lines_appended, 3);
|
||||
assert_eq!(model.synthetic_tick(), 1);
|
||||
assert_eq!(
|
||||
model.pane(PaneId::CodexRuntime).lines().last().unwrap(),
|
||||
"[tick 000001] codex-runtime · synthetic line 000001"
|
||||
);
|
||||
assert_eq!(model.pane(PaneId::Architecture).lines(), markdown);
|
||||
assert!(
|
||||
model
|
||||
.panes()
|
||||
.iter()
|
||||
.all(|pane| pane.synthetic_line_sequence() == 1)
|
||||
);
|
||||
assert_eq!(model.counters().synthetic_ticks, 1);
|
||||
assert_eq!(model.counters().surface_updates, 6);
|
||||
assert_eq!(model.counters().terminal_lines_appended, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_scrollback_is_bounded_below_fixture_size() {
|
||||
let mut model = ShellModel::with_terminal_line_limit(2).unwrap();
|
||||
assert_eq!(model.pane(PaneId::CodexRuntime).lines().len(), 2);
|
||||
for _ in 0..10 {
|
||||
model.dispatch(ShellAction::SyntheticStreamTick);
|
||||
}
|
||||
for pane in model
|
||||
.panes()
|
||||
.iter()
|
||||
.filter(|pane| pane.kind() == SurfaceKind::Terminal)
|
||||
{
|
||||
assert_eq!(pane.lines().len(), 2);
|
||||
assert_eq!(pane.synthetic_line_sequence(), 10);
|
||||
assert!(pane.lines()[0].starts_with("[tick 000009]"));
|
||||
assert!(pane.lines()[1].starts_with("[tick 000010]"));
|
||||
}
|
||||
assert!(ShellModel::with_terminal_line_limit(0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_action_replays_produce_identical_models_and_counters() {
|
||||
let actions = [
|
||||
ShellAction::MoveFocus(FocusDirection::Right),
|
||||
ShellAction::MoveFocus(FocusDirection::Down),
|
||||
ShellAction::OpenCommandPalette,
|
||||
ShellAction::SetCommandPaletteQuery("workspace".into()),
|
||||
ShellAction::CloseCommandPalette,
|
||||
ShellAction::SetNeedsInput {
|
||||
pane: PaneId::CodexRuntime,
|
||||
needs_input: true,
|
||||
},
|
||||
ShellAction::SyntheticStreamTick,
|
||||
ShellAction::SyntheticStreamTick,
|
||||
];
|
||||
let mut gpui = ShellModel::default();
|
||||
let mut floem = ShellModel::default();
|
||||
let gpui_outcomes = actions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|action| gpui.dispatch(action))
|
||||
.collect::<Vec<_>>();
|
||||
let floem_outcomes = actions
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|action| floem.dispatch(action))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(gpui_outcomes, floem_outcomes);
|
||||
assert_eq!(gpui, floem);
|
||||
assert_eq!(gpui.counters().events_dispatched, actions.len() as u64);
|
||||
assert_eq!(gpui.counters().surface_updates, 12);
|
||||
assert_eq!(gpui.counters().terminal_lines_appended, 6);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user