Build interactive native workspace vertical slice
CI / rust (push) Successful in 1m43s

This commit is contained in:
2026-08-31 16:15:32 -07:00
parent de89b015bc
commit 27beb69ff8
19 changed files with 10535 additions and 200 deletions
+421 -91
View File
@@ -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();
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]
+83
View File
@@ -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"]
+486
View File
@@ -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
View File
@@ -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);
});
}
+618 -5
View File
@@ -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);
}
}