Files
lumbridge-code/spikes/floem-shell/src/main.rs
T
2026-08-31 16:15:32 -07:00

494 lines
16 KiB
Rust

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_RIGHT, FocusDirection, PaneId, PaneState, ShellAction, ShellModel,
SurfaceKind, WORKSPACES,
};
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 dispatch(model: RwSignal<ShellModel>, action: ShellAction) {
model.update(|model| {
model.dispatch(action);
});
}
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)
})
}
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 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.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_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(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(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 grid = Stack::vertical((top_row, bottom_row)).style(|s| {
s.flex_basis(0)
.flex_grow(1.0)
.min_width(0.0)
.min_height(0.0)
.padding(4.0)
});
let header = Stack::horizontal((
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(48.0)
.padding_horiz(14.0)
.items_center()
.background(PANEL_ALT)
.border_bottom(1.0)
.border_color(BORDER_QUIET)
});
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::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(32.0)
.padding_horiz(10.0)
.items_center()
.font_size(10.0)
.color(MUTED)
.background(PANEL_ALT)
.border_top(1.0)
.border_color(BORDER_QUIET)
});
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() {
Application::new()
.window(
|_| app_view(),
Some(
WindowConfig::default()
.size(Size::new(1500.0, 960.0))
.min_size(Size::new(900.0, 600.0))
.title("Lumbridge · Floem workspace"),
),
)
.run();
}