feat: add Dioxus and GPUI UI prototypes for framework comparison
Dioxus (ui-dioxus/): 2,169 lines, WebView mode (same wry as Tauri), Catppuccin theme, 12 components, agor-core integration, compiles clean. Evolution path — keeps xterm.js, gradual migration from Tauri. GPUI (ui-gpui/): 2,490 lines, GPU-accelerated rendering, alacritty_terminal for native terminal, 17 files, Catppuccin palette, demo data. Revolution path — pure Rust UI, 120fps target, no WebView. Both are standalone (not in workspace), share agor-core backend. Created for side-by-side comparison to inform framework decision.
This commit is contained in:
parent
90c7315336
commit
f3d2ca78ba
34 changed files with 17467 additions and 0 deletions
9
ui-gpui/src/terminal/mod.rs
Normal file
9
ui-gpui/src/terminal/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//! GPU-rendered terminal using alacritty_terminal + GPUI rendering.
|
||||
//!
|
||||
//! This is the key differentiator vs. xterm.js:
|
||||
//! - No DOM/Canvas overhead — cells painted directly via GPU text pipeline
|
||||
//! - Same VT100 state machine as Alacritty (battle-tested)
|
||||
//! - PTY bridged through agor-core's PtyManager
|
||||
|
||||
pub mod pty_bridge;
|
||||
pub mod renderer;
|
||||
74
ui-gpui/src/terminal/pty_bridge.rs
Normal file
74
ui-gpui/src/terminal/pty_bridge.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! PTY integration via agor-core.
|
||||
//!
|
||||
//! Bridges agor-core's PtyManager to alacritty_terminal's event loop.
|
||||
//! Reads PTY output in a background thread, feeds it into the Term state machine,
|
||||
//! and notifies GPUI to repaint.
|
||||
|
||||
use agor_core::pty::{PtyManager, PtyOptions};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Manages a single PTY ↔ Terminal connection.
|
||||
pub struct PtyBridge {
|
||||
pub pty_id: Option<String>,
|
||||
pty_manager: Arc<PtyManager>,
|
||||
/// Raw bytes received from PTY, buffered for the renderer to consume.
|
||||
pub output_buffer: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl PtyBridge {
|
||||
pub fn new(pty_manager: Arc<PtyManager>) -> Self {
|
||||
Self {
|
||||
pty_id: None,
|
||||
pty_manager,
|
||||
output_buffer: Arc::new(Mutex::new(Vec::with_capacity(8192))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a PTY process and start reading its output.
|
||||
pub fn spawn(&mut self, cwd: &str) -> Result<(), String> {
|
||||
let id = self.pty_manager.spawn(PtyOptions {
|
||||
shell: None,
|
||||
cwd: Some(cwd.to_string()),
|
||||
args: None,
|
||||
cols: Some(120),
|
||||
rows: Some(30),
|
||||
})?;
|
||||
self.pty_id = Some(id);
|
||||
// NOTE: In a full implementation, we would start a background thread here
|
||||
// that reads from the PTY master fd and pushes bytes into output_buffer.
|
||||
// The PtyManager currently handles reading internally and emits events via
|
||||
// EventSink. For the GPUI bridge, we would intercept those events in the
|
||||
// Backend::drain_events() loop and feed them here.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write user input to the PTY.
|
||||
pub fn write(&self, data: &str) -> Result<(), String> {
|
||||
if let Some(ref id) = self.pty_id {
|
||||
self.pty_manager
|
||||
.write(id, data)
|
||||
.map_err(|e| format!("PTY write error: {e}"))
|
||||
} else {
|
||||
Err("No PTY spawned".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize the PTY.
|
||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
if let Some(ref id) = self.pty_id {
|
||||
self.pty_manager
|
||||
.resize(id, cols, rows)
|
||||
.map_err(|e| format!("PTY resize error: {e}"))
|
||||
} else {
|
||||
Err("No PTY spawned".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain buffered output bytes (consumed by the renderer).
|
||||
pub fn drain_output(&self) -> Vec<u8> {
|
||||
let mut buf = self.output_buffer.lock().unwrap();
|
||||
let data = buf.clone();
|
||||
buf.clear();
|
||||
data
|
||||
}
|
||||
}
|
||||
277
ui-gpui/src/terminal/renderer.rs
Normal file
277
ui-gpui/src/terminal/renderer.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! GPU text rendering for terminal cells.
|
||||
//!
|
||||
//! Uses alacritty_terminal::Term for the VT state machine and renders each cell
|
||||
//! as GPUI text elements. This is the "revolution" — no DOM, no Canvas 2D,
|
||||
//! just GPU-accelerated glyph rendering at 120fps.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! 1. `vte::ansi::Processor` parses raw PTY bytes
|
||||
//! 2. `alacritty_terminal::Term` (implements `vte::ansi::Handler`) processes escape sequences
|
||||
//! 3. Grid cells are read via `grid[Line(i)][Column(j)]`
|
||||
//! 4. Each cell is rendered as a GPUI div with the correct foreground color
|
||||
|
||||
use alacritty_terminal::event::{Event as AlacrittyEvent, EventListener};
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use alacritty_terminal::index::{Column, Line};
|
||||
use alacritty_terminal::term::Config as TermConfig;
|
||||
use alacritty_terminal::term::Term;
|
||||
use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor};
|
||||
use gpui::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
// ── Alacritty Event Listener (no-op for prototype) ──────────────────
|
||||
|
||||
struct GpuiTermEventListener;
|
||||
|
||||
impl EventListener for GpuiTermEventListener {
|
||||
fn send_event(&self, _event: AlacrittyEvent) {
|
||||
// In a full implementation, forward bell, title changes, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// ── ANSI Color Mapping ──────────────────────────────────────────────
|
||||
|
||||
/// Map alacritty's Color to Catppuccin Mocha Rgba.
|
||||
fn ansi_to_rgba(color: Color) -> Rgba {
|
||||
match color {
|
||||
Color::Named(named) => named_to_rgba(named),
|
||||
Color::Spec(rgb) => Rgba {
|
||||
r: rgb.r as f32 / 255.0,
|
||||
g: rgb.g as f32 / 255.0,
|
||||
b: rgb.b as f32 / 255.0,
|
||||
a: 1.0,
|
||||
},
|
||||
Color::Indexed(idx) => {
|
||||
if idx < 16 {
|
||||
// Map standard 16 colors to named
|
||||
match idx {
|
||||
0 => theme::SURFACE0,
|
||||
1 => theme::RED,
|
||||
2 => theme::GREEN,
|
||||
3 => theme::YELLOW,
|
||||
4 => theme::BLUE,
|
||||
5 => theme::MAUVE,
|
||||
6 => theme::TEAL,
|
||||
7 => theme::SUBTEXT1,
|
||||
8 => theme::OVERLAY0,
|
||||
9 => theme::FLAMINGO,
|
||||
10 => theme::GREEN,
|
||||
11 => theme::YELLOW,
|
||||
12 => theme::SAPPHIRE,
|
||||
13 => theme::PINK,
|
||||
14 => theme::SKY,
|
||||
15 => theme::TEXT,
|
||||
_ => theme::TEXT,
|
||||
}
|
||||
} else {
|
||||
// 216-color cube + 24 grayscale — approximate for prototype
|
||||
theme::TEXT
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn named_to_rgba(named: NamedColor) -> Rgba {
|
||||
match named {
|
||||
NamedColor::Black => theme::SURFACE0,
|
||||
NamedColor::Red => theme::RED,
|
||||
NamedColor::Green => theme::GREEN,
|
||||
NamedColor::Yellow => theme::YELLOW,
|
||||
NamedColor::Blue => theme::BLUE,
|
||||
NamedColor::Magenta => theme::MAUVE,
|
||||
NamedColor::Cyan => theme::TEAL,
|
||||
NamedColor::White => theme::TEXT,
|
||||
NamedColor::BrightBlack => theme::OVERLAY0,
|
||||
NamedColor::BrightRed => theme::FLAMINGO,
|
||||
NamedColor::BrightGreen => theme::GREEN,
|
||||
NamedColor::BrightYellow => theme::YELLOW,
|
||||
NamedColor::BrightBlue => theme::SAPPHIRE,
|
||||
NamedColor::BrightMagenta => theme::PINK,
|
||||
NamedColor::BrightCyan => theme::SKY,
|
||||
NamedColor::BrightWhite => theme::TEXT,
|
||||
NamedColor::Foreground => theme::TEXT,
|
||||
NamedColor::Background => theme::BASE,
|
||||
NamedColor::Cursor => theme::ROSEWATER,
|
||||
NamedColor::DimForeground => theme::SUBTEXT0,
|
||||
_ => theme::TEXT,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal Size Adapter ───────────────────────────────────────────
|
||||
|
||||
struct TerminalSize {
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl Dimensions for TerminalSize {
|
||||
fn total_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
fn screen_lines(&self) -> usize {
|
||||
self.rows
|
||||
}
|
||||
fn columns(&self) -> usize {
|
||||
self.cols
|
||||
}
|
||||
fn last_column(&self) -> Column {
|
||||
Column(self.cols.saturating_sub(1))
|
||||
}
|
||||
fn bottommost_line(&self) -> Line {
|
||||
Line(self.rows as i32 - 1)
|
||||
}
|
||||
fn topmost_line(&self) -> Line {
|
||||
Line(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terminal State ──────────────────────────────────────────────────
|
||||
|
||||
/// Wraps alacritty_terminal::Term with render cache for GPUI.
|
||||
pub struct TerminalState {
|
||||
term: Term<GpuiTermEventListener>,
|
||||
processor: Processor,
|
||||
pub cols: usize,
|
||||
pub rows: usize,
|
||||
/// Cached render data: Vec of rows, each row is Vec of (char, fg_color).
|
||||
render_lines: Vec<Vec<(char, Rgba)>>,
|
||||
}
|
||||
|
||||
impl TerminalState {
|
||||
pub fn new(cols: usize, rows: usize) -> Self {
|
||||
let size = TerminalSize { cols, rows };
|
||||
let config = TermConfig::default();
|
||||
let term = Term::new(config, &size, GpuiTermEventListener);
|
||||
let processor = Processor::new();
|
||||
|
||||
Self {
|
||||
term,
|
||||
processor,
|
||||
cols,
|
||||
rows,
|
||||
render_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed raw PTY output bytes into the VT state machine.
|
||||
pub fn process_output(&mut self, data: &[u8]) {
|
||||
self.processor.advance(&mut self.term, data);
|
||||
}
|
||||
|
||||
/// Rebuild the render_lines cache from the term grid.
|
||||
pub fn update_render_cache(&mut self) {
|
||||
let grid = self.term.grid();
|
||||
self.render_lines.clear();
|
||||
|
||||
for row_idx in 0..self.rows {
|
||||
let mut line = Vec::with_capacity(self.cols);
|
||||
let row = &grid[Line(row_idx as i32)];
|
||||
for col_idx in 0..self.cols {
|
||||
let cell = &row[Column(col_idx)];
|
||||
let ch = cell.c;
|
||||
let fg = ansi_to_rgba(cell.fg);
|
||||
line.push((ch, fg));
|
||||
}
|
||||
self.render_lines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the cached render lines.
|
||||
pub fn lines(&self) -> &[Vec<(char, Rgba)>] {
|
||||
&self.render_lines
|
||||
}
|
||||
|
||||
/// Get cursor position (row, col).
|
||||
pub fn cursor_point(&self) -> (usize, usize) {
|
||||
let cursor = self.term.grid().cursor.point;
|
||||
(cursor.line.0 as usize, cursor.column.0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── GPUI Terminal View ──────────────────────────────────────────────
|
||||
|
||||
/// GPUI view that renders the terminal grid.
|
||||
/// Each cell is rendered as a text span with the correct foreground color.
|
||||
/// The cursor is rendered as a block highlight.
|
||||
pub struct TerminalView {
|
||||
pub state: TerminalState,
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
pub fn new(cols: usize, rows: usize) -> Self {
|
||||
Self {
|
||||
state: TerminalState::new(cols, rows),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed demo content for visual testing.
|
||||
pub fn feed_demo(&mut self) {
|
||||
let demo = b"\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[32mcargo build\x1b[0m\r\n\
|
||||
\x1b[33mCompiling\x1b[0m agor-core v0.1.0\r\n\
|
||||
\x1b[33mCompiling\x1b[0m agor-gpui v0.1.0\r\n\
|
||||
\x1b[1;32m Finished\x1b[0m `dev` profile [unoptimized + debuginfo] in 4.2s\r\n\
|
||||
\x1b[1;34m~/code/ai/agent-orchestrator\x1b[0m $ \x1b[7m \x1b[0m";
|
||||
self.state.process_output(demo);
|
||||
self.state.update_render_cache();
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
// Update render cache from terminal state
|
||||
self.state.update_render_cache();
|
||||
let (cursor_row, cursor_col) = self.state.cursor_point();
|
||||
|
||||
// Build rows as horizontal flex containers of character spans
|
||||
let mut rows: Vec<Div> = Vec::new();
|
||||
|
||||
for (row_idx, line) in self.state.lines().iter().enumerate() {
|
||||
let mut row_div = div().flex().flex_row();
|
||||
|
||||
for (col_idx, &(ch, fg)) in line.iter().enumerate() {
|
||||
let is_cursor = row_idx == cursor_row && col_idx == cursor_col;
|
||||
let display_char = if ch == '\0' || ch == ' ' {
|
||||
' '
|
||||
} else {
|
||||
ch
|
||||
};
|
||||
|
||||
let mut cell = div()
|
||||
.w(px(8.4))
|
||||
.h(px(18.0))
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_size(px(14.0))
|
||||
.text_color(fg);
|
||||
|
||||
if is_cursor {
|
||||
cell = cell.bg(theme::ROSEWATER).text_color(theme::BASE);
|
||||
}
|
||||
|
||||
row_div = row_div.child(cell.child(format!("{}", display_char)));
|
||||
}
|
||||
|
||||
rows.push(row_div);
|
||||
}
|
||||
|
||||
// Terminal container
|
||||
let mut container = div()
|
||||
.w_full()
|
||||
.h_full()
|
||||
.bg(theme::BASE)
|
||||
.p(px(4.0))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.overflow_hidden()
|
||||
.font_family("JetBrains Mono");
|
||||
|
||||
for row in rows {
|
||||
container = container.child(row);
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue