494 lines
16 KiB
Rust
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();
|
|
}
|