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:
Hibryda 2026-03-19 06:05:58 +01:00
parent 90c7315336
commit f3d2ca78ba
34 changed files with 17467 additions and 0 deletions

View 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;

View 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
}
}

View 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
}
}