From dae7f351e28974eb9af86de206579b32647abfa9 Mon Sep 17 00:00:00 2001 From: Mayu-infinite Date: Sun, 26 Jul 2026 16:46:13 +0530 Subject: [PATCH] feat(client): structured modules + tachyonfx animations --- client/Cargo.toml | 1 + client/src/app.rs | 99 +++----------- client/src/effects.rs | 200 +++++++++++++++++++++++++++++ client/src/ipc/protocol.rs | 7 +- client/src/main.rs | 12 +- client/src/ui.rs | 87 +++++++++++++ client/src/widgets/conversation.rs | 2 +- client/src/widgets/status.rs | 10 -- 8 files changed, 316 insertions(+), 102 deletions(-) create mode 100644 client/src/effects.rs create mode 100644 client/src/ui.rs diff --git a/client/Cargo.toml b/client/Cargo.toml index 7172a72..1569e1f 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -13,3 +13,4 @@ uuid = { version = "1", features = ["v4", "serde"] } serde_json = "1" thiserror = "1" futures-util = "0.3" +tachyonfx = "=0.16.0" diff --git a/client/src/app.rs b/client/src/app.rs index 7f87d4b..832c829 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -1,20 +1,10 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; -use ratatui::{ - Frame, - layout::{Constraint, Direction, Layout, Position, Rect}, -}; - -use crate::widgets::{ - conversation::{Author, ConversationWidget, Message}, - events::EventStreamWidget, - header::BannerWidget, - prompt::PromptWidget, - status::StatusWidget, -}; -use crate::{ - ipc::{client::OrionClient, events::RuntimeEvent}, - theme::default_style, -}; +use ratatui::layout::{Position, Rect}; + +use crate::effects::Effects; +use crate::ipc::{client::OrionClient, events::RuntimeEvent}; +use crate::theme; +use crate::widgets::conversation::{Author, ConversationWidget, Message}; #[derive(Debug, PartialEq, Eq)] pub enum InputMode { @@ -38,6 +28,9 @@ pub struct App { // Store layout bounds for mouse click target checks pub prompt_area: Rect, + + // Animations (tachyonfx) + pub effects: Effects, } impl App { @@ -54,6 +47,7 @@ impl App { msg_counter: 0, client: None, prompt_area: Rect::default(), + effects: Effects::new(), } } @@ -76,6 +70,7 @@ impl App { pub fn enter_char(&mut self, new_char: char) { self.input.insert(self.cursor_position, new_char); self.move_cursor_right(); + self.effects.on_keystroke(); } pub fn delete_char(&mut self) { @@ -134,6 +129,7 @@ impl App { Author::User, content, )); + self.effects.on_message(); self.input.clear(); self.cursor_position = 0; // Reset cursor position on submit @@ -145,10 +141,12 @@ impl App { match event { RuntimeEvent::Connected => { self.mode = "CONNECTED".into(); + self.effects.on_status_change(theme::OK); } RuntimeEvent::Disconnected => { self.mode = "DISCONNECTED".into(); + self.effects.on_status_change(theme::DANGER); } RuntimeEvent::AssistantStart => { @@ -158,6 +156,7 @@ impl App { // Begin a single message bubble for the streaming response self.conversation .begin_assistant_message(format!("msg-{}", self.msg_counter)); + self.effects.on_message(); } RuntimeEvent::AssistantChunk(text) => { @@ -185,6 +184,7 @@ impl App { RuntimeEvent::Error { message, .. } => { self.mode = format!("ERROR: {}", message); + self.effects.on_status_change(theme::DANGER); } RuntimeEvent::Ping => { @@ -210,71 +210,4 @@ impl App { RuntimeEvent::Unknown(_) => {} } } - - pub fn draw(&mut self, frame: &mut Frame) { - let area = frame.area(); - frame.render_widget( - ratatui::widgets::Block::default().style(default_style()), - area, - ); - - // Main Vertical Split - let main_chunks = Layout::default() - .direction(Direction::Vertical) - .margin(1) - .constraints([ - Constraint::Length(2), // Banner - Constraint::Min(0), // Content - Constraint::Length(1), // Status Bar - ]) - .split(area); - - // Horizontal Middle Split: Left Column (66%), Right Column (34%) - let content_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) - .split(main_chunks[1]); - - // Left Column Vertical Split: Conversation Area (Flex) + Prompt Box (3 rows) - let left_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(0), // Conversation - Constraint::Length(3), // Prompt input box - ]) - .split(content_chunks[0]); - - // Cache prompt area for mouse click collision detection - self.prompt_area = left_chunks[1]; - - // 1. Render Banner - frame.render_widget(BannerWidget::render(main_chunks[0]), main_chunks[0]); - - // 2. Render Left Column - self.conversation.render(frame, left_chunks[0]); - - // Render Prompt with current cursor position - let is_focused = self.input_mode == InputMode::Insert; - PromptWidget::render( - frame, - left_chunks[1], - &self.input, - self.cursor_position, - is_focused, - ); - - // 3. Render Right Column - frame.render_widget(EventStreamWidget::render(), content_chunks[1]); - - // 4. Render Status Bar - frame.render_widget( - StatusWidget::update_status( - &self.mode, - &self.input_mode, - self.events_count, - self.frame_tick, - ), - main_chunks[2], - ); - } } diff --git a/client/src/effects.rs b/client/src/effects.rs new file mode 100644 index 0000000..3329f50 --- /dev/null +++ b/client/src/effects.rs @@ -0,0 +1,200 @@ +//! Animated visual effects, powered by [`tachyonfx`]. +//! +//! Effects are post-processing passes applied to the already-rendered frame +//! buffer each tick. [`Effects`] owns the named effects, exposes small +//! `on_*` triggers that [`crate::app::App`] calls on state changes, and +//! `render_*` passes that [`crate::ui`] calls with the relevant screen region +//! and the frame's elapsed time. +//! +//! The recipes are adapted from junkdog's `exabind` tachyonfx tech demo: +//! a layered sweep+coalesce intro (its `open_category`), a per-keystroke +//! "click" flash (its `key_press`), and a border glow that pulses while the +//! assistant is busy (its LED-border idea, scoped to border cells via +//! [`CellFilter::Outer`]). + +use ratatui::{ + Frame, + layout::{Margin, Rect}, + style::Color, +}; +use tachyonfx::{CellFilter, Duration, Effect, EffectRenderer, Interpolation, Motion, Shader, fx}; + +use crate::theme; + +/// Owns every animation in the client and drives it frame by frame. +pub struct Effects { + /// One-shot full-screen reveal played once on launch. + startup: Effect, + /// Endless, gentle "breathing" glow on the header accent. + header_shimmer: Effect, + /// Endless cyan border pulse — rendered only while the assistant is busy. + thinking: Effect, + /// Brief "materialize" pass when a new message bubble appears. + message_in: Option, + /// Brief tint flash on the status bar for connection / state changes. + status_pulse: Option, + /// Brief accent flash on the prompt for each keystroke. + prompt_flash: Option, +} + +impl Effects { + pub fn new() -> Self { + Self { + startup: startup_effect(), + header_shimmer: header_shimmer_effect(), + thinking: thinking_effect(), + message_in: None, + status_pulse: None, + prompt_flash: None, + } + } + + // --- triggers: called from `App` when state changes ------------------- + + /// A new message appeared — play a short materialize over the transcript. + pub fn on_message(&mut self) { + self.message_in = Some(message_in_effect()); + } + + /// Connection / activity state changed — flash the status bar in `color`. + pub fn on_status_change(&mut self, color: Color) { + self.status_pulse = Some(status_pulse_effect(color)); + } + + /// A key was typed into the prompt — flash the input accent. + pub fn on_keystroke(&mut self) { + self.prompt_flash = Some(prompt_flash_effect()); + } + + // --- render passes: called from `ui::draw` after widgets render ------- + + pub fn render_startup(&mut self, frame: &mut Frame, area: Rect, dt: Duration) { + if self.startup.running() { + frame.render_effect(&mut self.startup, area, dt); + } + } + + pub fn render_header(&mut self, frame: &mut Frame, area: Rect, dt: Duration) { + frame.render_effect(&mut self.header_shimmer, area, dt); + } + + /// Pulse the transcript border while `active` (assistant busy). + pub fn render_thinking(&mut self, frame: &mut Frame, area: Rect, dt: Duration, active: bool) { + if active { + frame.render_effect(&mut self.thinking, area, dt); + } + } + + pub fn render_conversation(&mut self, frame: &mut Frame, area: Rect, dt: Duration) { + Self::render_optional(&mut self.message_in, frame, area, dt); + } + + pub fn render_status(&mut self, frame: &mut Frame, area: Rect, dt: Duration) { + Self::render_optional(&mut self.status_pulse, frame, area, dt); + } + + pub fn render_prompt(&mut self, frame: &mut Frame, area: Rect, dt: Duration) { + Self::render_optional(&mut self.prompt_flash, frame, area, dt); + } + + /// Render a one-shot effect if it is still running, otherwise drop it. + fn render_optional(slot: &mut Option, frame: &mut Frame, area: Rect, dt: Duration) { + if let Some(effect) = slot.as_mut() { + if effect.running() { + frame.render_effect(effect, area, dt); + } else { + *slot = None; + } + } + } +} + +impl Default for Effects { + fn default() -> Self { + Self::new() + } +} + +// --- effect recipes ------------------------------------------------------- + +/// Layered intro: a left-to-right reveal with the whole frame coalescing in +/// underneath it (adapted from exabind's `open_category`). +fn startup_effect() -> Effect { + fx::parallel(&[ + fx::sweep_in( + Motion::LeftToRight, + 25, + 10, + theme::BG, + (850, Interpolation::QuadOut), + ), + fx::coalesce((900, Interpolation::SineOut)), + ]) +} + +/// A hue/lightness ping-pong on the header — a clearly visible "breathing" +/// glow so the UI reads as alive even while idle and offline. +fn header_shimmer_effect() -> Effect { + fx::repeating(fx::ping_pong(fx::hsl_shift( + Some([28.0, 0.0, 32.0]), + None, + (1100, Interpolation::SineInOut), + ))) +} + +/// Endless cyan pulse restricted to the border ring of whatever area it is +/// rendered on. Used on the transcript panel while the assistant is busy. +fn thinking_effect() -> Effect { + fx::repeating(fx::ping_pong( + fx::fade_to_fg(theme::ORION_ACCENT, (700, Interpolation::SineInOut)) + .with_filter(CellFilter::Outer(Margin::new(1, 1))), + )) +} + +/// New bubbles coalesce into place rather than snapping in. +fn message_in_effect() -> Effect { + fx::coalesce((380, Interpolation::SineOut)) +} + +/// A short foreground tint that fades back to normal. +fn status_pulse_effect(color: Color) -> Effect { + fx::fade_from_fg(color, (500, Interpolation::SineOut)) +} + +/// A quick "click" flash on the prompt for tactile keystroke feedback +/// (adapted from exabind's `key_press`). +fn prompt_flash_effect() -> Effect { + fx::fade_from_fg(theme::ORION_ACCENT, (220, Interpolation::SineOut)) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend}; + + /// Every effect must process over a real buffer without panicking. + #[test] + fn effects_render_without_panicking() { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let mut effects = Effects::new(); + effects.on_message(); + effects.on_status_change(theme::OK); + effects.on_keystroke(); + + // Drive several frames so one-shot effects run to completion. + for _ in 0..20 { + terminal + .draw(|frame| { + let area = frame.area(); + let dt = Duration::from_millis(16); + effects.render_startup(frame, area, dt); + effects.render_header(frame, area, dt); + effects.render_thinking(frame, area, dt, true); + effects.render_conversation(frame, area, dt); + effects.render_status(frame, area, dt); + effects.render_prompt(frame, area, dt); + }) + .unwrap(); + } + } +} diff --git a/client/src/ipc/protocol.rs b/client/src/ipc/protocol.rs index aef81f4..a7422a4 100644 --- a/client/src/ipc/protocol.rs +++ b/client/src/ipc/protocol.rs @@ -1,7 +1,4 @@ -use crate::ipc::{ - error::IpcError, - messages::Envelope, -}; +use crate::ipc::{error::IpcError, messages::Envelope}; /// Serialize an IPC message into bytes. /// @@ -16,4 +13,4 @@ pub fn encode(message: &Envelope) -> Result, IpcError> { /// Deserialize an IPC message received from the socket. pub fn decode(data: &str) -> Result { Ok(serde_json::from_str(data.trim_end())?) -} \ No newline at end of file +} diff --git a/client/src/main.rs b/client/src/main.rs index bec507a..cf7bea7 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,10 +1,12 @@ mod app; +mod effects; mod ipc; mod theme; +mod ui; mod widgets; use std::error::Error; -use std::time::Duration; +use std::time::{Duration, Instant}; use crossterm::{ event::{DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyModifiers}, @@ -19,7 +21,8 @@ use app::{App, InputMode}; use ipc::client::OrionClient; const SOCKET_PATH: &str = "/tmp/orion.sock"; -const TICK_RATE: Duration = Duration::from_millis(100); +// ~30 FPS so tachyonfx effects animate smoothly instead of stepping. +const TICK_RATE: Duration = Duration::from_millis(33); #[tokio::main] async fn main() -> Result<(), Box> { @@ -49,8 +52,11 @@ async fn main() -> Result<(), Box> { } // Main Async Event Loop + let mut last_frame = Instant::now(); loop { - terminal.draw(|f| app.draw(f))?; + let dt = last_frame.elapsed(); + last_frame = Instant::now(); + terminal.draw(|f| ui::draw(&mut app, f, dt.into()))?; if app.should_quit { break; diff --git a/client/src/ui.rs b/client/src/ui.rs new file mode 100644 index 0000000..936d920 --- /dev/null +++ b/client/src/ui.rs @@ -0,0 +1,87 @@ +//! Layout and rendering for the ORION client. +//! +//! Keeps all geometry in one place: [`App`] holds state, this module turns +//! that state into a frame, then lets [`crate::effects`] post-process it. + +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout}, + widgets::Block, +}; +use tachyonfx::Duration; + +use crate::app::{App, InputMode}; +use crate::theme::default_style; +use crate::widgets::{ + events::EventStreamWidget, header::BannerWidget, prompt::PromptWidget, status::StatusWidget, +}; + +/// Render one frame: widgets first, then the animated effect passes. +pub fn draw(app: &mut App, frame: &mut Frame, dt: Duration) { + let area = frame.area(); + + // Base background fill. + frame.render_widget(Block::default().style(default_style()), area); + + // Vertical: header · content · status. + let main = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(2), + Constraint::Min(0), + Constraint::Length(1), + ]) + .split(area); + + // Content: conversation column (66%) · event stream (34%). + let content = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(66), Constraint::Percentage(34)]) + .split(main[1]); + + // Left column: transcript (flex) · prompt (3 rows). + let left = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(3)]) + .split(content[0]); + + let header_area = main[0]; + let convo_area = left[0]; + let prompt_area = left[1]; + let events_area = content[1]; + let status_area = main[2]; + + // Cache prompt bounds for mouse hit-testing. + app.prompt_area = prompt_area; + + // --- widgets -------------------------------------------------------- + frame.render_widget(BannerWidget::render(header_area), header_area); + app.conversation.render(frame, convo_area); + + let is_focused = app.input_mode == InputMode::Insert; + PromptWidget::render( + frame, + prompt_area, + &app.input, + app.cursor_position, + is_focused, + ); + + frame.render_widget(EventStreamWidget::render(), events_area); + frame.render_widget( + StatusWidget::render(&app.mode, &app.input_mode, app.events_count, app.frame_tick), + status_area, + ); + + // --- effects (post-process the composed buffer) --------------------- + let busy = + matches!(app.mode.as_str(), "THINKING" | "RESPONDING") || app.mode.starts_with("TOOL"); + + app.effects.render_header(frame, header_area, dt); + app.effects.render_thinking(frame, convo_area, dt, busy); + app.effects.render_conversation(frame, convo_area, dt); + app.effects.render_prompt(frame, prompt_area, dt); + app.effects.render_status(frame, status_area, dt); + app.effects.render_startup(frame, area, dt); +} diff --git a/client/src/widgets/conversation.rs b/client/src/widgets/conversation.rs index 1e8310c..a4883dc 100644 --- a/client/src/widgets/conversation.rs +++ b/client/src/widgets/conversation.rs @@ -189,7 +189,7 @@ impl ConversationWidget { // Calculate bottom-up visible window including scroll offset let mut start_idx = 0; - let mut end_idx = self.messages.len().saturating_sub(self.scroll_offset); + let end_idx = self.messages.len().saturating_sub(self.scroll_offset); let mut accumulated_height = 0; for (i, &h) in message_heights[..end_idx].iter().enumerate().rev() { diff --git a/client/src/widgets/status.rs b/client/src/widgets/status.rs index f5c1a8a..c2f67ea 100644 --- a/client/src/widgets/status.rs +++ b/client/src/widgets/status.rs @@ -86,14 +86,4 @@ impl StatusWidget { Paragraph::new(content) } - - /// Convenience update helper to update state fields and build the Paragraph - pub fn update_status( - mode: &str, - input_mode: &InputMode, - events_count: usize, - frame_tick: usize, - ) -> Paragraph<'static> { - Self::render(mode, input_mode, events_count, frame_tick) - } }