From 3d6c339b7ffd550c3b74a5239c7d18c8261f70fb Mon Sep 17 00:00:00 2001 From: gaelcado Date: Thu, 15 Oct 2026 17:32:15 +0200 Subject: [PATCH 1/8] Voice glyph: a shared dot-matrix engine replaces the orb zeron-orb now holds one glyph engine for every platform: a field (the call's disc) and a beam (a strip), each deciding every dot's level, colour and size from the two voices' bands and the call's mode. Light is a halftone: dots swell and touch as they light, with one of the palette's three inks scattered at random per dot and never blended. Modes pass into each other through a layered passage, so a change mid-fade joins the blend. A pointer resting on a field lights the dots under it. --- crates/orb/Cargo.toml | 4 +- crates/orb/src/animator.rs | 300 ----- crates/orb/src/engine/braid.rs | 58 - crates/orb/src/engine/concepts.rs | 195 ---- crates/orb/src/engine/core.rs | 232 ---- crates/orb/src/engine/lattice.rs | 292 ----- crates/orb/src/engine/mod.rs | 82 -- crates/orb/src/engine/morph.rs | 172 --- crates/orb/src/engine/orbits.rs | 90 -- crates/orb/src/engine/profiles.rs | 385 ------- crates/orb/src/engine/ribbon.rs | 110 -- crates/orb/src/engine/web.rs | 120 -- crates/orb/src/glyph.rs | 1460 +++++++++++++++++++++++++ crates/orb/src/lib.rs | 46 +- crates/orb/src/motion.rs | 158 --- crates/orb/src/presets.rs | 354 ------ crates/orb/src/types.rs | 217 ---- crates/ui/tests/voice_orb_geometry.rs | 352 ------ 18 files changed, 1487 insertions(+), 3140 deletions(-) delete mode 100644 crates/orb/src/animator.rs delete mode 100644 crates/orb/src/engine/braid.rs delete mode 100644 crates/orb/src/engine/concepts.rs delete mode 100644 crates/orb/src/engine/core.rs delete mode 100644 crates/orb/src/engine/lattice.rs delete mode 100644 crates/orb/src/engine/mod.rs delete mode 100644 crates/orb/src/engine/morph.rs delete mode 100644 crates/orb/src/engine/orbits.rs delete mode 100644 crates/orb/src/engine/profiles.rs delete mode 100644 crates/orb/src/engine/ribbon.rs delete mode 100644 crates/orb/src/engine/web.rs create mode 100644 crates/orb/src/glyph.rs delete mode 100644 crates/orb/src/motion.rs delete mode 100644 crates/orb/src/presets.rs delete mode 100644 crates/orb/src/types.rs delete mode 100644 crates/ui/tests/voice_orb_geometry.rs diff --git a/crates/orb/Cargo.toml b/crates/orb/Cargo.toml index 504bf7c273..f8f57bbbf0 100644 --- a/crates/orb/Cargo.toml +++ b/crates/orb/Cargo.toml @@ -5,6 +5,6 @@ edition.workspace = true license.workspace = true publish.workspace = true -# The thinking-orb animation engine without a renderer: desktop paints its -# frames through GPUI, mobile through Core Graphics, from identical geometry. +# The voice glyph's engine without a renderer: desktop paints its frames +# through GPUI, mobile through Core Graphics, from identical dots. [dependencies] diff --git a/crates/orb/src/animator.rs b/crates/orb/src/animator.rs deleted file mode 100644 index 345de9a5e5..0000000000 --- a/crates/orb/src/animator.rs +++ /dev/null @@ -1,300 +0,0 @@ -//! Renderer-agnostic orb playback: the continuous clock, audio-driven speed -//! and state crossfades. Hosts decide when to tick and paint the frames. -use std::time::{Duration, Instant}; - -use crate::{ - REDUCED_MOTION_T, - engine::{Frame, draw_mode_into_resolved}, - motion::{AnimationClock, AudioResponse}, - presets::{Resolved, resolve_preset}, - types::{OrbSize, OrbState}, -}; - -struct StateTransition { - from: Frame, - source: Option, - started: f64, -} - -/// Blend two frames by opacity with a smoothstep weight. -pub fn crossfade(from: &Frame, to: &Frame, progress: f32, out: &mut Frame) { - out.clear(); - let progress = progress.clamp(0.0, 1.0); - let weight = progress * progress * (3.0 - 2.0 * progress); - for (frame, opacity) in [(from, 1.0 - weight), (to, weight)] { - out.dots.extend(frame.dots.iter().filter_map(|dot| { - let mut dot = *dot; - dot.a *= opacity; - (dot.a >= 0.005).then_some(dot) - })); - out.lines.extend(frame.lines.iter().filter_map(|line| { - let mut line = *line; - line.a *= opacity; - (line.a >= 0.005).then_some(line) - })); - } -} - -/// One orb's animation state. The engine takes continuous, unbounded time — -/// its modes mix incommensurate frequencies, so there is no seamless wrap -/// point — and this owns that clock. -pub struct OrbAnimator { - state: OrbState, - size: OrbSize, - /// Multiplier on top of the preset's baked speed. - speed: f32, - clock: AnimationClock, - audio: Option, - transition_duration: Duration, - transition: Option, - resolved: Resolved, - target_frame: Frame, - t: f32, -} - -impl OrbAnimator { - pub fn new(state: OrbState, size: OrbSize) -> Self { - Self { - state, - size, - speed: 1.0, - clock: AnimationClock::default(), - audio: None, - transition_duration: Duration::ZERO, - transition: None, - resolved: resolve_preset(state, size), - target_frame: Frame::new(), - t: 0.0, - } - } - - /// Crossfade state geometry without restarting the clock. - pub fn with_transition(mut self, duration: Duration) -> Self { - self.set_transition(duration); - self - } - - pub fn set_transition(&mut self, duration: Duration) { - self.transition_duration = duration; - } - - pub fn state(&self) -> OrbState { - self.state - } - - pub fn size(&self) -> OrbSize { - self.size - } - - pub fn speed(&self) -> f32 { - self.speed - } - - /// Logical edge length of the preset's artwork. - pub fn pixels(&self) -> f32 { - self.size.pixels() - } - - /// Smallest painted dot radius for the current preset. - pub fn r_min(&self) -> f32 { - self.resolved.opts.r_min.unwrap_or(0.3) - } - - pub fn is_running(&self) -> bool { - self.clock.is_running() - } - - pub fn is_transitioning(&self) -> bool { - self.transition.is_some() - } - - /// Change state. `visible` is the frame on screen now: an interrupted - /// fade continues from its visible composite instead of jumping back. - pub fn set_state(&mut self, state: OrbState, visible: &Frame) -> bool { - if self.state == state { - return false; - } - self.transition = (!self.transition_duration.is_zero() - && self.clock.is_running() - && (!visible.dots.is_empty() || !visible.lines.is_empty())) - .then(|| StateTransition { - from: visible.clone(), - // Animate the outgoing form, unless it is itself mid-fade. - source: self.transition.is_none().then(|| self.resolved.clone()), - started: self.clock.active_seconds, - }); - self.state = state; - self.resolved = resolve_preset(state, self.size); - true - } - - pub fn set_size(&mut self, size: OrbSize) -> bool { - if self.size == size { - return false; - } - self.size = size; - self.resolved = resolve_preset(self.state, size); - true - } - - pub fn set_speed(&mut self, speed: f32) -> bool { - let speed = if speed.is_finite() { - speed.clamp(0.0, 100.0) - } else { - 1.0 - }; - let changed = self.speed != speed; - self.speed = speed; - changed - } - - /// Audio affects only visual motion, with independent microphone and - /// speaker envelopes. - pub fn set_audio_levels(&mut self, microphone: f32, speaker: f32, now: Instant) -> bool { - self.audio - .get_or_insert_with(|| AudioResponse::new(now)) - .set(microphone, speaker, now) - } - - /// Freeze accumulated time (paused, hidden or reduced motion). - pub fn stop(&mut self, now: Instant) { - self.clock.stop(now); - } - - pub fn cancel_transition(&mut self) { - self.transition = None; - } - - /// Advance the clock to `now`. Time only accumulates while `animating`. - pub fn tick(&mut self, now: Instant, animating: bool, reduced: bool) { - let audio_speed = self.audio.as_ref().map_or(1.0, |audio| audio.speed(now)); - let rate = f64::from(self.resolved.speed) * f64::from(self.speed) * audio_speed; - self.clock.advance(now, rate, animating); - self.t = if reduced { - REDUCED_MOTION_T - } else { - self.clock.seconds as f32 - }; - if reduced { - self.transition = None; - } - } - - /// Geometry at the last tick, in the preset's logical pixels. - pub fn draw(&mut self, out: &mut Frame) { - let progress = self.transition.as_ref().map_or(1.0, |transition| { - ((self.clock.active_seconds - transition.started) - / self.transition_duration.as_secs_f64()) as f32 - }); - if progress >= 1.0 { - self.transition = None; - } - let size = self.size.pixels(); - let Some(transition) = &mut self.transition else { - // The allocation-free path outside a fade. - draw_mode_into_resolved(self.resolved.mode, size, self.t, &self.resolved.opts, out); - return; - }; - draw_mode_into_resolved( - self.resolved.mode, - size, - self.t, - &self.resolved.opts, - &mut self.target_frame, - ); - if let Some(source) = &transition.source { - draw_mode_into_resolved( - source.mode, - size, - self.t, - &source.opts, - &mut transition.from, - ); - } - crossfade(&transition.from, &self.target_frame, progress, out); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::engine::{Dot, Line}; - - #[test] - fn state_crossfade_preserves_endpoints_and_blends_dots_and_lines() { - let from = Frame { - dots: vec![Dot::new(1.0, 2.0, 0.0, 1.0, 0.5)], - lines: vec![Line { - x1: 0.0, - y1: 0.0, - x2: 1.0, - y2: 1.0, - white: 0.5, - a: 1.0, - w: 1.0, - }], - }; - let to = Frame { - dots: vec![Dot::new(3.0, 4.0, 0.0, 1.0, 0.5)], - lines: vec![], - }; - let mut out = Frame::new(); - crossfade(&from, &to, 0.0, &mut out); - assert_eq!(out.dots.len(), 1); - assert_eq!(out.dots[0].x, 1.0); - assert_eq!(out.lines[0].a, 1.0); - crossfade(&from, &to, 0.5, &mut out); - assert_eq!(out.dots.len(), 2); - assert_eq!(out.dots[0].a, 0.5); - assert_eq!(out.dots[1].a, 0.5); - assert_eq!(out.lines[0].a, 0.5); - crossfade(&from, &to, 1.0, &mut out); - assert_eq!(out.dots.len(), 1); - assert_eq!(out.dots[0].x, 3.0); - assert!(out.lines.is_empty()); - } - - #[test] - fn interrupted_state_transition_starts_from_the_visible_frame() { - let now = Instant::now(); - let mut orb = OrbAnimator::new(OrbState::Working, OrbSize::Avatar) - .with_transition(Duration::from_millis(300)); - orb.tick(now, true, false); - let mut visible = Frame::new(); - visible - .dots - .push(Dot::new(42.0, 0.0, 0.0, 1.0, 0.5).with_a(0.4)); - assert!(orb.set_state(OrbState::Listening, &visible)); - assert_eq!(orb.transition.as_ref().unwrap().from.dots[0].x, 42.0); - assert!(orb.transition.as_ref().unwrap().source.is_some()); - visible.dots[0].a = 0.2; - orb.set_state(OrbState::Composing, &visible); - let transition = orb.transition.as_ref().unwrap(); - assert_eq!(transition.from.dots[0].a, 0.2); - // A fade from a fade keeps the composite rather than redrawing it. - assert!(transition.source.is_none()); - orb.tick(now + Duration::from_millis(16), false, true); - assert!(orb.transition.is_none()); - assert!(!orb.is_running()); - } - - #[test] - fn stopped_orbs_switch_state_without_a_fade_and_finish_fades() { - let now = Instant::now(); - let mut orb = OrbAnimator::new(OrbState::Working, OrbSize::Hero) - .with_transition(Duration::from_millis(300)); - let mut frame = Frame::new(); - orb.tick(now, false, false); - orb.draw(&mut frame); - orb.set_state(OrbState::Listening, &frame); - assert!(!orb.is_transitioning()); - orb.tick(now, true, false); - orb.draw(&mut frame); - orb.set_state(OrbState::Composing, &frame); - assert!(orb.is_transitioning()); - orb.tick(now + Duration::from_millis(400), true, false); - orb.draw(&mut frame); - assert!(!orb.is_transitioning()); - assert!(!frame.dots.is_empty()); - } -} diff --git a/crates/orb/src/engine/braid.rs b/crates/orb/src/engine/braid.rs deleted file mode 100644 index 1168e10e3e..0000000000 --- a/crates/orb/src/engine/braid.rs +++ /dev/null @@ -1,58 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Braid: three strands plait around the sphere — the "weaving" state. - -use crate::engine::{ - core::{Dot, Frame, frac, make_proj, radius_scale, with_fib_dirs}, - profiles::{MAX_GHOST_N, MAX_STRAND_N, ModeOpts, count_usize}, -}; -use std::f32::consts::PI; - -pub fn draw_braid_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.76; - let pt = make_proj(t * 0.4, 0.3, cx, cy, 1.0); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - - let dots = &mut out.dots; - let ghost_n = count_usize(o.ghost_n, 150.0, 0, MAX_GHOST_N as usize); - let strand_n = count_usize(o.strand_n, 52.0, 0, MAX_STRAND_N as usize); - let inv_r = 1.0 / r; - dots.reserve(ghost_n.saturating_add(strand_n.saturating_mul(3))); - - with_fib_dirs(ghost_n, |directions| { - for d in directions { - let (px, py, z) = pt.project(d[0] * r, d[1] * r, d[2] * r); - let depth = (z * inv_r + 1.0) / 2.0; - dots.push(Dot::new(px, py, z, 0.8 * rs, 0.78).with_a(0.1 + 0.22 * depth)); - } - }); - - let turns = o.turns.unwrap_or(3.0); - let r_base = o.r_base.unwrap_or(1.2); - let r_depth = o.r_depth.unwrap_or(1.8); - let inv_strand = 1.0 / strand_n as f32; - for s in 0..3 { - let phase = (s as f32 / 3.0) * 2.0 * PI; - for i in 0..strand_n { - let u = (frac(i as f32 * inv_strand + t * 0.045) * 2.0 - 1.0) * 0.96; - let surf = (1.0 - u * u).max(0.0).sqrt(); - let end_fade = ((0.96 - u.abs()) / 0.12).clamp(0.0, 1.0); - let a = u * PI * turns + phase; - let weave = 1.0 + 0.075 * (u * PI * turns * 2.0 + phase * 2.0 + t * 0.8).sin(); - let rr = surf * r * weave; - let (px, py, zr) = pt.project(a.cos() * rr, u * r * weave, a.sin() * rr); - let depth = (zr * inv_r + 1.0) / 2.0; - dots.push( - Dot::new( - px, - py, - zr, - (r_base + r_depth * depth) * rs, - 0.55 - 0.45 * depth, - ) - .with_a(end_fade * (0.45 + 0.55 * depth)), - ); - } - } -} diff --git a/crates/orb/src/engine/concepts.rs b/crates/orb/src/engine/concepts.rs deleted file mode 100644 index 92ec0d0a97..0000000000 --- a/crates/orb/src/engine/concepts.rs +++ /dev/null @@ -1,195 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Original low-density orb concepts: focus, gyroscope, and memory echoes. -//! -//! These modes deliberately reuse the engine's existing count/radius knobs and -//! emit dots only, keeping them on the cheapest GPUI paint path. - -use crate::engine::{ - core::{Dot, Frame, frac, make_proj, radius_scale, with_unit_circle}, - profiles::{MAX_LANES, MAX_PARTICLES, MAX_SEGS, ModeOpts, count_usize}, -}; -use std::f32::consts::PI; - -/// Iris-like streams converge on a small, steady focal core. -pub fn draw_focus_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let center = size / 2.0; - let extent = size * 0.38 * o.spread.unwrap_or(1.0); - let lanes = count_usize(o.lanes, 6.0, 1, MAX_LANES as usize); - let segs = count_usize(o.segs, 12.0, 1, MAX_SEGS as usize); - let core_n = count_usize(o.particles, 5.0, 1, MAX_PARTICLES as usize); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let r_base = o.r_base.unwrap_or(0.9); - let r_depth = o.r_depth.unwrap_or(1.25); - let total = lanes.saturating_mul(segs).saturating_add(core_n); - out.dots.reserve(total); - - // Close and reopen a six-blade aperture without collapsing the samples - // into a star. Every blade keeps a continuous curved silhouette; only its - // inner edge moves, so the concept remains readable in a frozen frame. - let openness = 0.5 + 0.5 * (t * 0.9).sin(); - let inner = 0.12 + 0.2 * openness; - let rotation = t * 0.11; - let inv_segs = 1.0 / segs as f32; - with_unit_circle(lanes, |directions| { - for (lane, direction) in directions.iter().enumerate() { - for i in 0..segs { - let u = (i as f32 + 0.5) * inv_segs; - let radial = inner + (1.0 - inner) * u; - let curl = (0.88 - 0.24 * openness) * (1.0 - u).powf(1.25); - let twist = rotation + curl; - let (st, ct) = twist.sin_cos(); - let dx = direction[0] * ct - direction[1] * st; - let dy = direction[0] * st + direction[1] * ct; - let inner_weight = 1.0 - u; - let shimmer = 0.96 + 0.05 * (t * 1.4 + lane as f32 * 0.8).sin(); - out.dots.push( - Dot::new( - center + dx * extent * radial, - center + dy * extent * radial, - 0.0, - (r_base + r_depth * inner_weight) * shimmer * rs, - 0.12 + 0.52 * u, - ) - .with_a(0.48 + 0.52 * (PI * u).sin()), - ); - } - } - }); - - // A tiny breathing nucleus makes the destination legible at inline size. - let core_radius = size * (0.035 + 0.004 * (t * 1.5).sin()); - with_unit_circle(core_n, |circle| { - for p in circle { - out.dots.push(Dot::new( - center + p[0] * core_radius, - center + p[1] * core_radius, - 0.0, - (r_base + r_depth * 1.15) * rs, - 0.08, - )); - } - }); -} - -/// Three inference loops carry thoughts in alternating directions. -pub fn draw_gyroscope_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let center = size / 2.0; - let radius = size * 0.385 * o.spread.unwrap_or(1.0); - let rings = count_usize(o.lanes, 3.0, 1, MAX_LANES as usize).min(6); - let segs = count_usize(o.segs, 24.0, 3, MAX_SEGS as usize); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let r_base = o.r_base.unwrap_or(0.8); - let r_depth = o.r_depth.unwrap_or(1.5); - let proj = make_proj(t * 0.055, 0.18, center, center, radius); - out.dots - .reserve(rings.saturating_mul(segs.saturating_add(2))); - - with_unit_circle(segs, |circle| { - for ring in 0..rings { - let spread = if rings > 1 { - ring as f32 / (rings - 1) as f32 - 0.5 - } else { - 0.0 - }; - let tilt_x = spread * 1.55 + 0.12 * (t * 0.16 + ring as f32).sin(); - let tilt_y = ring as f32 * PI / rings as f32 + 0.32; - let (sx, cx) = tilt_x.sin_cos(); - let (sy, cy) = tilt_y.sin_cos(); - let direction = if ring % 2 == 0 { 1.0 } else { -1.0 }; - let orient = |ca: f32, sa: f32| { - let y1 = sa * cx; - let z1 = sa * sx; - (ca * cy + z1 * sy, y1, -ca * sy + z1 * cy) - }; - - for p in circle { - let (x, y, z) = orient(p[0], p[1]); - let (px, py, depth_z) = proj.project(x, y, z); - let depth = (depth_z + 1.0) * 0.5; - out.dots.push( - Dot::new( - px, - py, - depth_z, - (r_base * 0.9 + r_depth * 0.5 * depth) * rs, - 0.62 - 0.34 * depth, - ) - .with_a(0.34 + 0.4 * depth), - ); - } - - // A leading thought and a softer echo make direction and velocity - // legible without turning the complete track into visual noise. - let travel = - t * (0.72 + ring as f32 * 0.08) * direction + ring as f32 * 2.0 * PI / rings as f32; - for (trail, offset) in [0.0, -0.22 * direction].into_iter().enumerate() { - let (sa, ca) = (travel + offset).sin_cos(); - let (x, y, z) = orient(ca, sa); - let (px, py, depth_z) = proj.project(x, y, z); - let depth = (depth_z + 1.0) * 0.5; - let strength = if trail == 0 { 1.0 } else { 0.48 }; - out.dots.push( - Dot::new( - px, - py, - depth_z + 0.002, - (r_base + r_depth * depth + 1.65 * strength) * rs, - 0.44 - 0.42 * depth - 0.2 * strength, - ) - .with_a((0.55 + 0.45 * depth) * strength), - ); - } - } - }); -} - -/// Concentric echoes emerge from a stable core and dissolve at the boundary. -pub fn draw_echo_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let center = size / 2.0; - let extent = size * 0.4 * o.spread.unwrap_or(1.0); - let rings = count_usize(o.lanes, 4.0, 1, MAX_LANES as usize); - let segs = count_usize(o.segs, 18.0, 3, MAX_SEGS as usize); - let core_n = count_usize(o.particles, 3.0, 1, MAX_PARTICLES as usize); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let r_base = o.r_base.unwrap_or(0.85); - let r_depth = o.r_depth.unwrap_or(1.05); - out.dots - .reserve(rings.saturating_mul(segs).saturating_add(core_n)); - - with_unit_circle(segs, |circle| { - for ring in 0..rings { - let phase = frac(t * 0.14 + ring as f32 / rings as f32); - let radius = extent * (0.13 + 0.87 * phase); - let life = (PI * phase).sin().max(0.0); - let turn = ring as f32 * 0.37 - t * 0.1; - let (st, ct) = turn.sin_cos(); - for p in circle { - let x = p[0] * ct - p[1] * st; - let y = p[0] * st + p[1] * ct; - out.dots.push( - Dot::new( - center + x * radius, - center + y * radius, - 0.0, - (r_base + r_depth * (1.0 - phase)) * rs, - 0.18 + 0.52 * phase, - ) - .with_a(life.powf(0.58)), - ); - } - } - }); - - let core_radius = size * 0.025; - with_unit_circle(core_n, |circle| { - for p in circle { - out.dots.push(Dot::new( - center + p[0] * core_radius, - center + p[1] * core_radius, - 0.0, - (r_base + r_depth * 1.4) * rs, - 0.06, - )); - } - }); -} diff --git a/crates/orb/src/engine/core.rs b/crates/orb/src/engine/core.rs deleted file mode 100644 index fc55d78e54..0000000000 --- a/crates/orb/src/engine/core.rs +++ /dev/null @@ -1,232 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Shared primitives for the dotted 3D thought-orbs. -//! Ported from thinking-orbs / inkform (HalftoneSphere lineage). - -use std::{cell::RefCell, f32::consts::PI}; - -type Vec3 = [f32; 3]; -type Vec2 = [f32; 2]; - -thread_local! { - /// Per-thread topology caches, indexed directly by sample count. Public - /// draw inputs are capped before reaching these helpers, so the sparse - /// vectors stay small while lookup remains O(1). - static FIBONACCI_DIRECTIONS: RefCell>>> = const { RefCell::new(Vec::new()) }; - static UNIT_CIRCLES: RefCell>>> = const { RefCell::new(Vec::new()) }; -} - -/// A projected, depth-shaded disk. -#[derive(Clone, Copy, Debug)] -pub struct Dot { - pub x: f32, - pub y: f32, - pub z: f32, - pub r: f32, - /// Ink value: 0 = darkest ink on paper. Mirrored on dark themes. - pub white: f32, - pub a: f32, -} - -impl Dot { - pub fn new(x: f32, y: f32, z: f32, r: f32, white: f32) -> Self { - Self { - x, - y, - z, - r, - white, - a: 1.0, - } - } - - pub fn with_a(mut self, a: f32) -> Self { - self.a = a; - self - } -} - -/// A stroked edge between two projected points (the `connecting` web). -#[derive(Clone, Copy, Debug)] -pub struct Line { - pub x1: f32, - pub y1: f32, - pub x2: f32, - pub y2: f32, - pub white: f32, - pub a: f32, - pub w: f32, -} - -/// Frame geometry produced by a mode painter (backend-agnostic). -#[derive(Clone, Debug, Default)] -pub struct Frame { - pub dots: Vec, - pub lines: Vec, -} - -impl Frame { - pub fn new() -> Self { - Self::default() - } - - /// Drop all geometry but keep the allocated capacity, so a steady-state - /// animation loop reuses one pair of buffers forever. - #[inline] - pub fn clear(&mut self) { - self.dots.clear(); - self.lines.clear(); - } -} - -/// Spin + tilt + orthographic projection, precomputed once per frame. -/// -/// This used to be a `Box`, which meant a heap allocation on every -/// `draw_mode` call and an indirect call per dot. As a plain struct it is -/// stack-allocated and `project` inlines into the dot loops. -#[derive(Clone, Copy, Debug)] -pub struct Proj { - st: f32, - ct: f32, - sy: f32, - cyw: f32, - cx: f32, - cy: f32, - scale: f32, -} - -impl Proj { - #[inline] - pub fn project(&self, x: f32, y: f32, z: f32) -> (f32, f32, f32) { - let x1 = x * self.cyw + z * self.sy; - let z1 = -x * self.sy + z * self.cyw; - let y1 = y * self.ct - z1 * self.st; - let z2 = y * self.st + z1 * self.ct; - (self.cx + x1 * self.scale, self.cy - y1 * self.scale, z2) - } -} - -#[inline] -pub fn lerp(a: f32, b: f32, f: f32) -> f32 { - a + (b - a) * f -} - -#[inline] -pub fn frac(x: f32) -> f32 { - x - x.floor() -} - -/// Deterministic hash in [0, 1). -/// -/// The magic numbers are the canonical GLSL `fract(sin(dot(...)) * 43758.5453)` -/// constants, kept verbatim from upstream so the generated dot layouts match. -/// They carry more digits than `f32` can hold; that is intentional — they are -/// quoted as written, and truncating them would be a gratuitous divergence. -#[inline] -#[allow(clippy::excessive_precision)] -pub fn hash_d(a: f32, b: f32) -> f32 { - let h = (a * 12.9898 + b * 78.233).sin() * 43758.5453; - h - h.floor() -} - -/// Value noise on a 2D lattice — smooth, deterministic, cheap. -pub fn vnoise(x: f32, y: f32) -> f32 { - let xi = x.floor(); - let yi = y.floor(); - let mut fx = x - xi; - let mut fy = y - yi; - fx = fx * fx * (3.0 - 2.0 * fx); - fy = fy * fy * (3.0 - 2.0 * fy); - let a = hash_d(xi, yi); - let b = hash_d(xi + 1.0, yi); - let c = hash_d(xi, yi + 1.0); - let d = hash_d(xi + 1.0, yi + 1.0); - a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy -} - -/// Stable directions on a unit sphere (Fibonacci lattice). -pub fn fib_dir(i: usize, n: usize) -> (f32, f32, f32) { - let golden = PI * (3.0 - 5.0_f32.sqrt()); - let y = 1.0 - (2.0 * (i as f32 + 0.5)) / n as f32; - let rad = (1.0 - y * y).sqrt(); - let a = i as f32 * golden; - (rad * a.cos(), y, rad * a.sin()) -} - -/// Borrow the stable Fibonacci sphere for `n` points from a thread-local cache. -/// Profiles reuse the same handful of sanitized counts every frame, so their -/// square roots and trigonometry are normally paid only once. -pub fn with_fib_dirs(n: usize, f: impl FnOnce(&[Vec3]) -> R) -> R { - FIBONACCI_DIRECTIONS.with(|cell| { - let mut cache = cell.borrow_mut(); - if cache.len() <= n { - cache.resize_with(n + 1, || None); - } - let directions = cache[n].get_or_insert_with(|| { - (0..n) - .map(|i| { - let (x, y, z) = fib_dir(i, n); - [x, y, z] - }) - .collect::>() - .into_boxed_slice() - }); - f(directions) - }) -} - -/// Borrow evenly spaced `(cos θ, sin θ)` samples for a closed circle. -/// Circle topology occurs in almost every mode and is invariant across frames. -pub fn with_unit_circle(n: usize, f: impl FnOnce(&[Vec2]) -> R) -> R { - UNIT_CIRCLES.with(|cell| { - let mut cache = cell.borrow_mut(); - if cache.len() <= n { - cache.resize_with(n + 1, || None); - } - let circle = cache[n].get_or_insert_with(|| { - let step = 2.0 * PI / n.max(1) as f32; - (0..n) - .map(|i| { - let a = i as f32 * step; - [a.cos(), a.sin()] - }) - .collect::>() - .into_boxed_slice() - }); - f(circle) - }) -} - -/// Shortest signed angular distance, wrapped to (-π, π]. -#[inline] -pub fn angle_delta(a: f32, b: f32) -> f32 { - (a - b).sin().atan2((a - b).cos()) -} - -/// Shared spin + tilt + orthographic projection. -pub fn make_proj(yaw: f32, tilt: f32, cx: f32, cy: f32, scale: f32) -> Proj { - Proj { - st: tilt.sin(), - ct: tilt.cos(), - sy: yaw.sin(), - cyw: yaw.cos(), - cx, - cy, - scale, - } -} - -/// Dot radii were tuned for a 300pt frame; sub-linear scaling keeps small -/// spinners legible. Lower pow = radii shrink less with size. -#[inline] -pub fn radius_scale(size: f32, pow: f32) -> f32 { - (size / 300.0).powf(pow) -} - -/// Z-sort far→near so nearer dots paint over farther ones. -/// -/// `sort_unstable_by` is used deliberately: it sorts in place (the stable sort -/// allocates a scratch buffer every call) and ties only happen between dots at -/// identical depth, where paint order is visually irrelevant. -pub fn sort_dots(dots: &mut [Dot]) { - dots.sort_unstable_by(|a, b| a.z.partial_cmp(&b.z).unwrap_or(std::cmp::Ordering::Equal)); -} diff --git a/crates/orb/src/engine/lattice.rs b/crates/orb/src/engine/lattice.rs deleted file mode 100644 index 319797d509..0000000000 --- a/crates/orb/src/engine/lattice.rs +++ /dev/null @@ -1,292 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Sphere-lattice modes: globe (searching), rubik (solving), wave (listening). - -use crate::engine::{ - core::{Dot, Frame, angle_delta, hash_d, make_proj, radius_scale, with_unit_circle}, - profiles::{MAX_LATTICE_RINGS, MAX_LON_DENSITY, MAX_MOVE_COUNT, ModeOpts, count_usize}, -}; -use std::{cell::RefCell, f32::consts::PI}; - -struct Move { - axis: u8, - lo: f32, - hi: f32, - ang: f32, -} - -thread_local! { - /// Scratch for Rubik band moves — reused every frame so Solving is - /// allocation-free in steady state. - static MOVES: RefCell> = const { RefCell::new(Vec::new()) }; - static AMOUNTS: RefCell> = const { RefCell::new(Vec::new()) }; -} - -fn solve_cycle_into( - time: f32, - count: usize, - slot_dur: f32, - rest: f32, - amount: &mut Vec, -) -> (i32, f32) { - amount.clear(); - amount.resize(count, 0.0); - let mut active = -1_i32; - if count == 0 { - return (active, 0.0); - } - let cyc = 2.0 * count as f32 * slot_dur + rest; - let tc = time % cyc; - if tc < 2.0 * count as f32 * slot_dur { - // The guard above and this division are two independent float - // operations, so rounding can let `tc / slot_dur` reach `2 * count` - // even though `tc < 2 * count * slot_dur` held — e.g. count = 9, - // slot_dur = 0.42, tc = 7.559999466. Without the clamp `2*count-1-slot` - // wraps to `usize::MAX` and the loop below indexes out of bounds. - let slot = ((tc / slot_dur).floor() as usize).min(2 * count - 1); - let p = (tc - slot as f32 * slot_dur) / slot_dur; - let p = p.clamp(0.0, 1.0); - let ep = p * p * (3.0 - 2.0 * p); - let emphasis = (PI * p).sin().max(0.0).sqrt(); - if slot < count { - for a in amount.iter_mut().take(slot) { - *a = 1.0; - } - amount[slot] = ep; - active = slot as i32; - } else { - let u = 2 * count - 1 - slot; - for a in amount.iter_mut().take(u) { - *a = 1.0; - } - if u < count { - amount[u] = 1.0 - ep; - } - active = u as i32; - } - return (active, emphasis); - } - (active, 0.0) -} - -fn apply_moves( - pt3: (f32, f32, f32), - moves: &[Move], - amounts: &[f32], - active: i32, -) -> (f32, f32, f32, bool) { - let (mut x, mut y, mut z) = pt3; - let mut in_active = false; - for (i, mv) in moves.iter().enumerate() { - if amounts[i] <= 0.0 { - continue; - } - let coord = match mv.axis { - 0 => x, - 1 => y, - _ => z, - }; - if coord < mv.lo || coord >= mv.hi { - continue; - } - if i as i32 == active { - in_active = true; - } - let a = mv.ang * amounts[i]; - let ca = a.cos(); - let sa = a.sin(); - match mv.axis { - 0 => { - let y2 = y * ca - z * sa; - z = y * sa + z * ca; - y = y2; - } - 1 => { - let x2 = x * ca + z * sa; - z = -x * sa + z * ca; - x = x2; - } - _ => { - let x2 = x * ca - y * sa; - y = x * sa + y * ca; - x = x2; - } - } - } - (x, y, z, in_active) -} - -fn make_moves_into(count: usize, moves: &mut Vec) { - moves.clear(); - moves.reserve(count); - for i in 0..count { - let i_f = i as f32; - let axis = (hash_d(i_f, 2.3) * 3.0).floor().min(2.0) as u8; - let lo = -1.0 + 0.5 * (hash_d(i_f, 5.9) * 4.0).floor().min(3.0); - let dir = if hash_d(i_f, 7.7) < 0.5 { 1.0 } else { -1.0 }; - moves.push(Move { - axis, - lo, - hi: lo + 0.5, - ang: dir * PI / 2.0, - }); - } -} - -/// Globe: lat/long field, a scan meridian sweeps — searching. -pub fn draw_globe_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let spin = 0.5; - let cx = size / 2.0; - let cy = size / 2.0; - let radius = (size / 2.0) * 0.82; - let tilt = 0.4 + 0.06 * (t * 0.35).sin(); - let pt = make_proj(t * spin, tilt, cx, cy, radius); - let scan = t * (spin + (1.7 - spin) * o.scan_mul.unwrap_or(1.0)); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let dim_base = o.dim_base.unwrap_or(1.0); - - let dots = &mut out.dots; - let lat_rings = count_usize(o.lat_rings, 17.0, 1, MAX_LATTICE_RINGS as usize); - let lon_density = o.lon_density.unwrap_or(44.0).clamp(1.0, MAX_LON_DENSITY); - let r_base = o.r_base.unwrap_or(0.6); - let r_depth = o.r_depth.unwrap_or(1.7); - let r_boost = o.r_boost.unwrap_or(1.0); - let ink_far = o.ink_far.unwrap_or(0.62); - let ink_span = o.ink_span.unwrap_or(0.54); - let inv_lat = 1.0 / lat_rings as f32; - - for li in 0..=lat_rings { - let lat = -PI / 2.0 + (li as f32 * inv_lat) * PI; - let cos_lat = lat.cos(); - let sin_lat = lat.sin(); - let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); - let lon_step = 2.0 * PI / lon_count as f32; - with_unit_circle(lon_count, |circle| { - for (lj, p) in circle.iter().enumerate() { - let lon = lj as f32 * lon_step; - let (clon, slon) = (p[0], p[1]); - let (px, py, z) = pt.project(cos_lat * clon, sin_lat, cos_lat * slon); - let depth = (z + 1.0) / 2.0; - let d = angle_delta(lon + t * spin, scan); - let boost = (-(d * d) / 0.18).exp() * z.max(0.0); - dots.push( - Dot::new( - px, - py, - z, - (r_base + r_depth * depth + r_boost * boost) * rs, - ink_far - ink_span * depth, - ) - .with_a(dim_base + (1.0 - dim_base) * boost.min(1.0)), - ); - } - }); - } -} - -/// Rubik: bands twist in quarter turns, scramble → solve — solving. -pub fn draw_rubik_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.82; - // Keep the lattice almost still so each quarter-turn reads as the action, - // rather than getting lost inside a continuously spinning globe. - let pt = make_proj(t * 0.12, 0.35 + 0.035 * (t * 0.45).sin(), cx, cy, r); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let move_count = count_usize(o.move_count, 14.0, 0, MAX_MOVE_COUNT as usize); - - MOVES.with(|moves_cell| { - AMOUNTS.with(|amounts_cell| { - let mut moves = moves_cell.borrow_mut(); - let mut amounts = amounts_cell.borrow_mut(); - if moves.len() != move_count { - make_moves_into(move_count, &mut moves); - } - let (active, emphasis) = solve_cycle_into(t, move_count, 0.58, 1.35, &mut amounts); - - let dots = &mut out.dots; - let lat_rings = count_usize(o.lat_rings, 15.0, 1, MAX_LATTICE_RINGS as usize); - let lon_density = o.lon_density.unwrap_or(40.0).clamp(1.0, MAX_LON_DENSITY); - let r_base = o.r_base.unwrap_or(0.6); - let r_depth = o.r_depth.unwrap_or(1.7); - let r_active = o.r_active.unwrap_or(0.3); - let ink_far = o.ink_far.unwrap_or(0.62); - let ink_span = o.ink_span.unwrap_or(0.54); - let inv_lat = 1.0 / lat_rings as f32; - - for li in 0..=lat_rings { - let lat = -PI / 2.0 + (li as f32 * inv_lat) * PI; - let cos_lat = lat.cos(); - let sin_lat = lat.sin(); - let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); - with_unit_circle(lon_count, |circle| { - for p in circle { - let (x, y, z, in_active) = apply_moves( - (cos_lat * p[0], sin_lat, cos_lat * p[1]), - &moves, - &amounts, - active, - ); - let (px, py, zr) = pt.project(x, y, z); - let depth = (zr + 1.0) / 2.0; - let active_mix = if in_active { emphasis } else { 0.0 }; - dots.push( - Dot::new( - px, - py, - zr, - (r_base + r_depth * depth + r_active * 2.1 * active_mix) * rs, - ink_far - ink_span * depth - 0.24 * active_mix, - ) - .with_a(0.58 + 0.14 * depth + 0.28 * active_mix), - ); - } - }); - } - }); - }); -} - -/// Wave: a waveform rolls through the rings — listening. -pub fn draw_wave_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.874; - let pt = make_proj(t * 0.18, 0.38, cx, cy, 1.0); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - - let dots = &mut out.dots; - let rings = count_usize(o.rings, 15.0, 1, MAX_LATTICE_RINGS as usize); - let lon_density = o.lon_density.unwrap_or(40.0).clamp(1.0, MAX_LON_DENSITY); - let r_base = o.r_base.unwrap_or(0.6); - let r_depth = o.r_depth.unwrap_or(1.7); - let inv_rings = 1.0 / rings as f32; - let inv_r = 1.0 / r; - - for ri in 0..=rings { - let lat = -PI / 2.0 + (ri as f32 * inv_rings) * PI; - let cos_lat = lat.cos(); - let sin_lat = lat.sin(); - let w = - 0.62 * (t * 2.1 - ri as f32 * 0.52).sin() + 0.38 * (t * 1.27 + ri as f32 * 0.83).sin(); - let rr = r * (0.88 + 0.105 * w); - let lon_count = ((cos_lat.abs() * lon_density).round() as usize).max(1); - let crest = w.max(0.0); - let crest_r = 1.0 + 0.4 * crest; - let crest_ink = 0.1 * crest; - with_unit_circle(lon_count, |circle| { - for p in circle { - let (clon, slon) = (p[0], p[1]); - let (px, py, z) = - pt.project(cos_lat * clon * rr, sin_lat * rr, cos_lat * slon * rr); - let depth = (z * inv_r + 1.0) / 2.0; - dots.push(Dot::new( - px, - py, - z, - (r_base + r_depth * depth) * crest_r * rs, - 0.66 - 0.56 * depth - crest_ink, - )); - } - }); - } -} diff --git a/crates/orb/src/engine/mod.rs b/crates/orb/src/engine/mod.rs deleted file mode 100644 index 1cc153c7bd..0000000000 --- a/crates/orb/src/engine/mod.rs +++ /dev/null @@ -1,82 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Backend-agnostic animation engine (ported from thinking-orbs). - -mod braid; -mod concepts; -mod core; -mod lattice; -mod morph; -mod orbits; -mod profiles; -mod ribbon; -mod web; - -pub use core::{Dot, Frame, Line, Proj, make_proj, sort_dots}; -pub use profiles::{ - MAX_ICON_D, MAX_MORPH_DOTS, MAX_NODE_N, MAX_SIZE, MIN_SIZE, ModeOpts, base_profile, - sanitize_mode_opts, sanitize_size, scale_counts, scale_radii, -}; - -use crate::types::ModeKey; -use braid::draw_braid_into; -use concepts::{draw_echo_into, draw_focus_into, draw_gyroscope_into}; -use lattice::{draw_globe_into, draw_rubik_into, draw_wave_into}; -use morph::draw_morph_into; -use orbits::draw_orbits_into; -use ribbon::draw_ribbon_into; -use web::draw_web_into; - -/// Paint one frame for the given mode into an existing [`Frame`], reusing its -/// buffers. -/// -/// This is the allocation-free entry point for the **geometry** buffers: `out` -/// is cleared but keeps its capacity, so a steady-state animation loop performs -/// no heap growth after the first frame (the Solving mode reuses thread-local -/// scratch for its move tables). Prefer this over [`draw_mode`] in render loops. -/// -/// `size` and every count-like field of `opts` are sanitized (finite + hard -/// ceilings) before geometry runs — see [`sanitize_mode_opts`]. -pub fn draw_mode_into(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts, out: &mut Frame) { - let size = sanitize_size(size); - let opts = sanitize_mode_opts(opts); - let t = if t.is_finite() { t } else { 0.0 }; - draw_mode_into_resolved(mode, size, t, &opts, out); -} - -/// Fast path for the widget's already-sanitized, size-bounded presets. -/// -/// Only for options from [`crate::resolve_preset`], which are already finite -/// and bounded: it skips the 40-field clone/sanitize pass on every widget -/// tick. Arbitrary options must go through [`draw_mode_into`]. -#[doc(hidden)] -pub fn draw_mode_into_resolved(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts, out: &mut Frame) { - out.clear(); - match mode { - ModeKey::Orbits => draw_orbits_into(size, t, opts, out), - ModeKey::Globe => draw_globe_into(size, t, opts, out), - ModeKey::Rubik => draw_rubik_into(size, t, opts, out), - ModeKey::Wave => draw_wave_into(size, t, opts, out), - ModeKey::Web => draw_web_into(size, t, opts, out), - ModeKey::Braid => draw_braid_into(size, t, opts, out), - ModeKey::Ribbon | ModeKey::Ring => draw_ribbon_into(size, t, opts, out), - ModeKey::Morph => draw_morph_into(size, t, opts, out), - ModeKey::Focus => draw_focus_into(size, t, opts, out), - ModeKey::Gyroscope => draw_gyroscope_into(size, t, opts, out), - ModeKey::Echo => draw_echo_into(size, t, opts, out), - } - // Face-on modes intentionally emit z=0 in painter order, so a comparison - // sort cannot change their result. Avoid it entirely. - if !matches!(mode, ModeKey::Focus | ModeKey::Echo) { - sort_dots(&mut out.dots); - } -} - -/// Paint one frame for the given mode into a fresh [`Frame`]. -/// -/// Convenience wrapper around [`draw_mode_into`] for one-off calls and tests; -/// it allocates. Render loops should use [`draw_mode_into`] instead. -pub fn draw_mode(mode: ModeKey, size: f32, t: f32, opts: &ModeOpts) -> Frame { - let mut frame = Frame::new(); - draw_mode_into(mode, size, t, opts, &mut frame); - frame -} diff --git a/crates/orb/src/engine/morph.rs b/crates/orb/src/engine/morph.rs deleted file mode 100644 index 4c5467b727..0000000000 --- a/crates/orb/src/engine/morph.rs +++ /dev/null @@ -1,172 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Morph: dotted outline cycling circle → triangle → square — "shaping". - -use crate::engine::{ - core::{Dot, Frame}, - profiles::{MAX_MORPH_DOTS, ModeOpts}, -}; -use std::f32::consts::PI; - -/// Outline resolution used to re-parameterise the blended shape by arc length. -const M_SAMPLES: usize = 160; -/// Largest vertex count across the shipped polygons (square, incl. the -/// repeated closing vertex). -const MAX_VERTS: usize = 8; - -const TRIANGLE: [(f32, f32); 3] = [(0.0, -0.26), (0.24, 0.16), (-0.24, 0.16)]; -const SQUARE: [(f32, f32); 4] = [(0.0, -0.2), (0.2, -0.2), (0.2, 0.2), (-0.2, 0.2)]; - -fn smooth_e(x: f32) -> f32 { - x * x * (3.0 - 2.0 * x) -} - -fn circle_path(f: f32) -> (f32, f32) { - let a = -PI / 2.0 + f * 2.0 * PI; - (a.cos() * 0.24, a.sin() * 0.24) -} - -/// Arc-length parameterisation of a closed polygon. -/// -/// Used to be a `Box` built three times per frame; now it is a plain -/// stack value with fixed-capacity length table. -struct PolyPath { - verts: &'static [(f32, f32)], - lengths: [f32; MAX_VERTS], - total: f32, -} - -impl PolyPath { - fn new(verts: &'static [(f32, f32)]) -> Self { - debug_assert!(verts.len() <= MAX_VERTS); - let mut lengths = [0.0; MAX_VERTS]; - let mut total = 0.0; - let v = verts.len(); - for i in 0..v { - let a = verts[i]; - let b = verts[(i + 1) % v]; - let l = (b.0 - a.0).hypot(b.1 - a.1); - lengths[i] = l; - total += l; - } - Self { - verts, - lengths, - total, - } - } - - fn at(&self, f: f32) -> (f32, f32) { - let v = self.verts.len(); - let mut target = f * self.total; - let mut i = 0; - // Bounds check first: `i` can never leave `0..v`. - while i < v - 1 && target > self.lengths[i] { - target -= self.lengths[i]; - i += 1; - } - let a = self.verts[i]; - let b = self.verts[(i + 1) % v]; - let ff = if self.lengths[i] > 0.0 { - (target / self.lengths[i]).min(1.0) - } else { - 0.0 - }; - (a.0 + (b.0 - a.0) * ff, a.1 + (b.1 - a.1) * ff) - } -} - -enum Shape { - Circle, - Poly(PolyPath), -} - -impl Shape { - fn at(&self, f: f32) -> (f32, f32) { - match self { - Shape::Circle => circle_path(f), - Shape::Poly(p) => p.at(f), - } - } -} - -fn morph_n(d: f32) -> usize { - let n = (34.0 * d).round().max(6.0); - (n as usize).clamp(6, MAX_MORPH_DOTS) -} - -const HOLD: f32 = 1.4; -const MORPH: f32 = 0.9; -const SEG: f32 = HOLD + MORPH; - -pub fn draw_morph_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cycle = [ - Shape::Circle, - Shape::Poly(PolyPath::new(&TRIANGLE)), - Shape::Poly(PolyPath::new(&SQUARE)), - ]; - let k_len = cycle.len(); - let tc = t.rem_euclid(SEG * k_len as f32); - let k = ((tc / SEG).floor() as usize).min(k_len - 1); - let local = tc - k as f32 * SEG; - let m = if local > HOLD { - smooth_e((local - HOLD) / MORPH) - } else { - 0.0 - }; - let sprd = o.spread.unwrap_or(1.0); - - let p_a = &cycle[k]; - let p_b = &cycle[(k + 1) % k_len]; - - // Fixed-size stack scratch — no per-frame heap traffic. - let mut pts = [(0.0f32, 0.0f32); M_SAMPLES]; - let mut lengths = [0.0f32; M_SAMPLES]; - let inv_samples = 1.0 / M_SAMPLES as f32; - for (i, p) in pts.iter_mut().enumerate() { - let f = i as f32 * inv_samples; - let a = p_a.at(f); - let b = p_b.at(f); - *p = ( - (a.0 + (b.0 - a.0) * m) * sprd, - (a.1 + (b.1 - a.1) * m) * sprd, - ); - } - let mut total = 0.0f32; - for i in 0..M_SAMPLES { - let a = pts[i]; - let b = pts[(i + 1) % M_SAMPLES]; - let l = (b.0 - a.0).hypot(b.1 - a.1); - lengths[i] = l; - total += l; - } - - let n = morph_n(o.icon_d.unwrap_or(1.0)); - let re = o.r_dot.unwrap_or(0.021) * 1.35 * sprd; - let pulse = 1.0 + 0.018 * (t * 2.35).sin(); - - let dots = &mut out.dots; - dots.reserve(n); - let c2 = size / 2.0; - let radius = (re * size).max(0.35); - let mut seg = 0usize; - let mut acc = 0.0f32; - let inv_n = 1.0 / n as f32; - for k2 in 0..n { - let target = k2 as f32 * inv_n * total; - // Bounds check first: `seg` can never leave `0..M_SAMPLES`. - while seg < M_SAMPLES - 1 && acc + lengths[seg] < target { - acc += lengths[seg]; - seg += 1; - } - let a = pts[seg]; - let b = pts[(seg + 1) % M_SAMPLES]; - let f = if lengths[seg] > 0.0 { - ((target - acc) / lengths[seg]).min(1.0) - } else { - 0.0 - }; - let x = (a.0 + (b.0 - a.0) * f) * pulse; - let y = (a.1 + (b.1 - a.1) * f) * pulse; - dots.push(Dot::new(c2 + x * size, c2 + y * size, 0.0, radius, 0.1)); - } -} diff --git a/crates/orb/src/engine/orbits.rs b/crates/orb/src/engine/orbits.rs deleted file mode 100644 index b29b30fb8d..0000000000 --- a/crates/orb/src/engine/orbits.rs +++ /dev/null @@ -1,90 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Orbits: particles on tilted orbits — the "working" state. - -use crate::engine::{ - core::{Dot, Frame, hash_d, make_proj, radius_scale, with_unit_circle}, - profiles::{MAX_GHOST_N, MAX_ORBIT_N, MAX_PARTICLES, ModeOpts, count_usize}, -}; -use std::f32::consts::PI; - -pub fn draw_orbits_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.82; - let pt = make_proj(t * 0.12, 0.3, cx, cy, 1.0); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - - let orbit_n = count_usize(o.orbit_n, 12.0, 0, MAX_ORBIT_N as usize); - let ghost_n = count_usize(o.ghost_n, 40.0, 0, MAX_GHOST_N as usize); - let particles = count_usize(o.particles, 3.0, 0, MAX_PARTICLES as usize); - - let dots = &mut out.dots; - let per_orbit = ghost_n.saturating_add(particles); - dots.reserve(orbit_n.saturating_mul(per_orbit)); - let ghost_r = o.ghost_r.unwrap_or(0.9) * rs; - let ghost_a = o.ghost_a.unwrap_or(0.5); - let part_r = o.part_r.unwrap_or(1.2); - let part_r_depth = o.part_r_depth.unwrap_or(1.6); - - for orb in 0..orbit_n { - let orb_f = orb as f32; - let h1 = hash_d(orb_f, 1.7); - let h2 = hash_d(orb_f, 5.2); - let h3 = hash_d(orb_f, 8.9); - let ro = r * (0.45 + 0.52 * h1); - let th = h1 * 2.0 * PI; - let phi = (2.0 * h2 - 1.0).acos(); - // Build a stable plane basis by crossing the normal with whichever - // world axis is least parallel to it. - let nx = phi.sin() * th.cos(); - let ny = phi.cos(); - let nz = phi.sin() * th.sin(); - let (mut ux, mut uy, mut uz) = if nz.abs() < 0.9 { - (-ny, nx, 0.0) - } else { - (0.0, -nz, ny) - }; - let ul = (ux * ux + uy * uy + uz * uz).sqrt().max(1e-6); - ux /= ul; - uy /= ul; - uz /= ul; - let vx = ny * uz - nz * uy; - let vy = nz * ux - nx * uz; - let vz = nx * uy - ny * ux; - let speed = (0.25 + 0.55 * h3) * if h3 > 0.5 { 1.0 } else { -1.0 }; - let inv_ro = 1.0 / ro; - - // Ghost topology is invariant; reuse its unit-circle samples instead - // of evaluating 2 × orbit_n × ghost_n trig functions every frame. - with_unit_circle(ghost_n, |circle| { - for p in circle { - let (ca, sa) = (p[0], p[1]); - let (px, py, z) = pt.project( - (ux * ca + vx * sa) * ro, - (uy * ca + vy * sa) * ro, - (uz * ca + vz * sa) * ro, - ); - let depth = (z * inv_ro + 1.0) / 2.0; - dots.push(Dot::new(px, py, z, ghost_r, 0.72).with_a(ghost_a * (0.4 + 0.6 * depth))); - } - }); - // the particles doing the work - for m in 0..particles { - let a = t * speed + (m as f32 / particles as f32) * 2.0 * PI + h2 * 6.0; - let (ca, sa) = (a.cos(), a.sin()); - let (px, py, z) = pt.project( - (ux * ca + vx * sa) * ro, - (uy * ca + vy * sa) * ro, - (uz * ca + vz * sa) * ro, - ); - let depth = (z * inv_ro + 1.0) / 2.0; - dots.push(Dot::new( - px, - py, - z, - (part_r + part_r_depth * depth) * rs, - 0.3 - 0.22 * depth, - )); - } - } -} diff --git a/crates/orb/src/engine/profiles.rs b/crates/orb/src/engine/profiles.rs deleted file mode 100644 index 78640cbdd2..0000000000 --- a/crates/orb/src/engine/profiles.rs +++ /dev/null @@ -1,385 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Density profiles + the multiplier machinery that scales them. - -/// Hard ceilings for count-like knobs. Preset values sit well below these; -/// they only bite adversarial or mistaken power-user inputs. -pub const MAX_LATTICE_RINGS: f32 = 128.0; -pub const MAX_LON_DENSITY: f32 = 256.0; -pub const MAX_ORBIT_N: f32 = 64.0; -pub const MAX_GHOST_N: f32 = 512.0; -pub const MAX_PARTICLES: f32 = 16.0; -pub const MAX_NODE_N: f32 = 128.0; -pub const MAX_SIGNALS: f32 = 64.0; -pub const MAX_STRAND_N: f32 = 256.0; -pub const MAX_LANES: f32 = 32.0; -pub const MAX_SEGS: f32 = 512.0; -pub const MAX_MOVE_COUNT: f32 = 64.0; -pub const MAX_ICON_D: f32 = 8.0; -/// Max dots the Morph painter will emit after `icon_d` is applied. -pub const MAX_MORPH_DOTS: usize = 512; -/// Logical size (px) accepted by [`crate::engine::draw_mode_into`]. -pub const MIN_SIZE: f32 = 1.0; -pub const MAX_SIZE: f32 = 1024.0; - -/// Free-form numeric knobs for mode painters (mirrors upstream `ModeOpts`). -/// -/// **Power-user / advanced API.** The normal path is the host widget / -/// [`crate::resolve_preset`], which only ever pass hand-tuned finite values. -/// -/// If you build a [`ModeOpts`] yourself and pass it to -/// [`crate::engine::draw_mode`] / [`crate::engine::draw_mode_into`], every count-like field is -/// clamped by [`sanitize_mode_opts`] before geometry runs: -/// -/// | Field family | Range after sanitize | -/// |--------------|----------------------| -/// | lattice rings / density | 1…128 / 1…256 | -/// | `orbit_n`, `ghost_n`, `particles` | 0…64 / 0…512 / 0…16 | -/// | `node_n`, `signals` | 0…128 / 0…64 | -/// | `strand_n`, `lanes`, `segs` | 0…256 / 1…32 / 1…512 | -/// | `move_count` | 0…64 | -/// | `icon_d` | 0.02…8 | -/// -/// Non-finite values fall back to the field default used by the painter. -/// New fields may be added without a major version bump. -#[derive(Clone, Debug, Default)] -#[non_exhaustive] -pub struct ModeOpts { - // lattice - pub lat_rings: Option, - pub lon_density: Option, - pub rings: Option, - pub r_base: Option, - pub r_depth: Option, - pub r_boost: Option, - pub r_active: Option, - pub ink_far: Option, - pub ink_span: Option, - pub rs_pow: Option, - pub r_min: Option, - pub move_count: Option, - pub scan_mul: Option, - pub dim_base: Option, - // orbits - pub orbit_n: Option, - pub ghost_n: Option, - pub ghost_r: Option, - pub ghost_a: Option, - pub particles: Option, - pub part_r: Option, - pub part_r_depth: Option, - // web - pub node_n: Option, - pub thr: Option, - pub signals: Option, - pub node_r: Option, - pub node_r_depth: Option, - pub line_w: Option, - // braid - pub strand_n: Option, - pub turns: Option, - // ribbon / ring - pub lanes: Option, - pub segs: Option, - pub face_on: Option, - pub spin: Option, - pub band_mul: Option, - pub wob_mul: Option, - // morph - pub r_dot: Option, - pub icon_d: Option, - pub spread: Option, - pub r_size_mul: Option, -} - -/// Clamp a free-form float option into a finite range, or leave `None`. -fn clamp_opt(v: Option, min: f32, max: f32) -> Option { - v.and_then(|x| { - if x.is_finite() { - Some(x.clamp(min, max)) - } else { - None - } - }) -} - -/// Return a safe copy of `opts` with every count-like / radius-like knob -/// finite and within the hard ceilings above. -/// -/// Called automatically by [`crate::engine::draw_mode_into`]. Safe to call yourself -/// when composing custom profiles. -pub fn sanitize_mode_opts(opts: &ModeOpts) -> ModeOpts { - ModeOpts { - lat_rings: clamp_opt(opts.lat_rings, 1.0, MAX_LATTICE_RINGS), - lon_density: clamp_opt(opts.lon_density, 1.0, MAX_LON_DENSITY), - rings: clamp_opt(opts.rings, 1.0, MAX_LATTICE_RINGS), - r_base: clamp_opt(opts.r_base, 0.0, 64.0), - r_depth: clamp_opt(opts.r_depth, 0.0, 64.0), - r_boost: clamp_opt(opts.r_boost, 0.0, 64.0), - r_active: clamp_opt(opts.r_active, 0.0, 64.0), - ink_far: clamp_opt(opts.ink_far, 0.0, 1.0), - ink_span: clamp_opt(opts.ink_span, 0.0, 2.0), - rs_pow: clamp_opt(opts.rs_pow, 0.05, 4.0), - r_min: clamp_opt(opts.r_min, 0.0, 32.0), - move_count: clamp_opt(opts.move_count, 0.0, MAX_MOVE_COUNT), - scan_mul: clamp_opt(opts.scan_mul, 0.0, 16.0), - dim_base: clamp_opt(opts.dim_base, 0.0, 1.0), - orbit_n: clamp_opt(opts.orbit_n, 0.0, MAX_ORBIT_N), - ghost_n: clamp_opt(opts.ghost_n, 0.0, MAX_GHOST_N), - ghost_r: clamp_opt(opts.ghost_r, 0.0, 64.0), - ghost_a: clamp_opt(opts.ghost_a, 0.0, 1.0), - particles: clamp_opt(opts.particles, 0.0, MAX_PARTICLES), - part_r: clamp_opt(opts.part_r, 0.0, 64.0), - part_r_depth: clamp_opt(opts.part_r_depth, 0.0, 64.0), - node_n: clamp_opt(opts.node_n, 0.0, MAX_NODE_N), - thr: clamp_opt(opts.thr, 0.0, 4.0), - signals: clamp_opt(opts.signals, 0.0, MAX_SIGNALS), - node_r: clamp_opt(opts.node_r, 0.0, 64.0), - node_r_depth: clamp_opt(opts.node_r_depth, 0.0, 64.0), - line_w: clamp_opt(opts.line_w, 0.0, 32.0), - strand_n: clamp_opt(opts.strand_n, 0.0, MAX_STRAND_N), - turns: clamp_opt(opts.turns, 0.0, 32.0), - lanes: clamp_opt(opts.lanes, 1.0, MAX_LANES), - segs: clamp_opt(opts.segs, 1.0, MAX_SEGS), - face_on: clamp_opt(opts.face_on, 0.0, 1.0), - spin: clamp_opt(opts.spin, -16.0, 16.0), - band_mul: clamp_opt(opts.band_mul, 0.05, 8.0), - wob_mul: clamp_opt(opts.wob_mul, 0.0, 8.0), - r_dot: clamp_opt(opts.r_dot, 0.0, 1.0), - icon_d: clamp_opt(opts.icon_d, 0.02, MAX_ICON_D), - spread: clamp_opt(opts.spread, 0.05, 4.0), - r_size_mul: clamp_opt(opts.r_size_mul, 0.0, 64.0), - } -} - -/// Clamp the logical paint size in pixels. -pub fn sanitize_size(size: f32) -> f32 { - if size.is_finite() { - size.clamp(MIN_SIZE, MAX_SIZE) - } else { - 64.0 - } -} - -/// Interpret a count-like option as `usize` after sanitization has run. -/// -/// Prefer calling this on already-sanitized opts. Non-finite / missing use -/// `default`; the result is always in `min..=max`. -#[inline] -pub fn count_usize(v: Option, default: f32, min: usize, max: usize) -> usize { - let x = match v { - Some(x) if x.is_finite() => x, - _ => default, - }; - let lo = min as f32; - let hi = max as f32; - (x.round().clamp(lo, hi)) as usize -} - -/// Scale total dot density. 2-D lattices (rings × dots-per-ring) take √scale -/// each side so the TOTAL scales by `scale`. Flat lists scale linearly. -pub fn scale_counts(opts: &ModeOpts, scale: f32) -> ModeOpts { - let mut out = opts.clone(); - let scale = if scale.is_finite() && scale > 0.0 { - scale - } else { - 1.0 - }; - let rt = scale.sqrt(); - - // Upstream COUNT_PAIRS: latRings×lonDensity first (owns lonDensity), - // then rings×lonDensity only if latRings was absent. - if let (Some(va), Some(vb)) = (out.lat_rings, out.lon_density) { - out.lat_rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS)); - out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY)); - } else if let (Some(va), Some(vb)) = (out.rings, out.lon_density) { - out.rings = Some((va * rt).round().clamp(2.0, MAX_LATTICE_RINGS)); - out.lon_density = Some((vb * rt).round().clamp(2.0, MAX_LON_DENSITY)); - } - // lanes × segs pair - if let (Some(va), Some(vb)) = (out.lanes, out.segs) { - out.lanes = Some((va * rt).round().clamp(2.0, MAX_LANES)); - out.segs = Some((vb * rt).round().clamp(2.0, MAX_SEGS)); - } - - // linear count keys (0 means opt-out — do not resurrect) - let scale_key = |v: Option, max: f32| match v { - Some(0.0) => Some(0.0), - Some(v) if v.is_finite() => Some((v * scale).round().clamp(1.0, max)), - Some(_) => None, - None => None, - }; - out.orbit_n = scale_key(out.orbit_n, MAX_ORBIT_N); - out.ghost_n = scale_key(out.ghost_n, MAX_GHOST_N); - out.node_n = scale_key(out.node_n, MAX_NODE_N); - out.strand_n = scale_key(out.strand_n, MAX_STRAND_N); - out.signals = scale_key(out.signals, MAX_SIGNALS); - - if let Some(v) = out.icon_d { - if v.is_finite() { - out.icon_d = Some((v * scale).clamp(0.02, MAX_ICON_D)); - } else { - out.icon_d = None; - } - } - out -} - -/// Scale every key that sets a dot's rendered radius. -pub fn scale_radii(opts: &ModeOpts, scale: f32) -> ModeOpts { - let mut out = opts.clone(); - let scale = if scale.is_finite() { scale } else { 1.0 }; - let mul = |v: Option| v.and_then(|x| x.is_finite().then_some(x * scale)); - out.r_base = mul(out.r_base); - out.r_depth = mul(out.r_depth); - out.r_active = mul(out.r_active); - out.r_dot = mul(out.r_dot); - out.ghost_r = mul(out.ghost_r); - out.part_r = mul(out.part_r); - out.part_r_depth = mul(out.part_r_depth); - out.node_r = mul(out.node_r); - out.node_r_depth = mul(out.node_r_depth); - out.r_size_mul = Some(out.r_size_mul.unwrap_or(1.0) * scale); - out -} - -impl ModeOpts { - /// The base profile for `mode`, then hand the mutable options over for the - /// power-user tweaks a preset cannot express. The struct is - /// `#[non_exhaustive]`, so this is the only way to build one outside the - /// crate. - pub fn fill(mode: crate::types::ModeKey, f: impl FnOnce(&mut Self)) -> Self { - let mut opts = base_profile(mode); - f(&mut opts); - opts - } -} - -/// Base (fine) profiles per mode, before preset multipliers. -pub fn base_profile(mode: crate::types::ModeKey) -> ModeOpts { - use crate::types::ModeKey::*; - match mode { - Globe => ModeOpts { - lat_rings: Some(17.0), - lon_density: Some(44.0), - r_base: Some(0.6), - r_depth: Some(1.7), - r_boost: Some(1.0), - ink_far: Some(0.62), - ink_span: Some(0.54), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Orbits => ModeOpts { - orbit_n: Some(12.0), - ghost_n: Some(40.0), - ghost_r: Some(0.9), - ghost_a: Some(0.5), - particles: Some(3.0), - part_r: Some(1.2), - part_r_depth: Some(1.6), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Rubik => ModeOpts { - lat_rings: Some(15.0), - lon_density: Some(40.0), - move_count: Some(14.0), - r_base: Some(0.6), - r_depth: Some(1.7), - r_active: Some(0.3), - ink_far: Some(0.62), - ink_span: Some(0.54), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Wave => ModeOpts { - rings: Some(15.0), - lon_density: Some(40.0), - r_base: Some(0.6), - r_depth: Some(1.7), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Web => ModeOpts { - node_n: Some(30.0), - thr: Some(0.72), - signals: Some(5.0), - node_r: Some(1.4), - node_r_depth: Some(1.8), - line_w: Some(0.8), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Braid => ModeOpts { - strand_n: Some(52.0), - turns: Some(3.0), - ghost_n: Some(150.0), - r_base: Some(1.2), - r_depth: Some(1.8), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Ribbon => ModeOpts { - lanes: Some(5.0), - segs: Some(88.0), - ghost_n: Some(150.0), - r_base: Some(1.1), - r_depth: Some(1.7), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Ring => ModeOpts { - lanes: Some(5.0), - segs: Some(88.0), - ghost_n: Some(0.0), - face_on: Some(1.0), - r_base: Some(1.1), - r_depth: Some(1.7), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Morph => ModeOpts { - r_dot: Some(0.021), - icon_d: Some(1.0), - r_min: Some(0.25), - ..Default::default() - }, - Focus => ModeOpts { - lanes: Some(6.0), - segs: Some(12.0), - particles: Some(5.0), - r_base: Some(0.9), - r_depth: Some(1.25), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Gyroscope => ModeOpts { - lanes: Some(3.0), - segs: Some(28.0), - r_base: Some(0.8), - r_depth: Some(1.5), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - Echo => ModeOpts { - lanes: Some(4.0), - segs: Some(18.0), - particles: Some(3.0), - r_base: Some(0.85), - r_depth: Some(1.05), - rs_pow: Some(0.6), - r_min: Some(0.3), - ..Default::default() - }, - } -} diff --git a/crates/orb/src/engine/ribbon.rs b/crates/orb/src/engine/ribbon.rs deleted file mode 100644 index 2b4ae5568a..0000000000 --- a/crates/orb/src/engine/ribbon.rs +++ /dev/null @@ -1,110 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Ribbon / ring: undulating sash or face-on breathing ring. - -use crate::engine::{ - core::{Dot, Frame, make_proj, radius_scale, with_fib_dirs}, - profiles::{MAX_GHOST_N, MAX_LANES, MAX_SEGS, ModeOpts, count_usize}, -}; -use std::f32::consts::PI; - -pub fn draw_ribbon_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.78; - let spin = o.spin.unwrap_or(1.0); - let cam_tilt = 0.3; - let pt = make_proj(t * 0.1 * spin, cam_tilt, cx, cy, 1.0); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - let face_on = o.face_on.unwrap_or(0.0) != 0.0; - - let dots = &mut out.dots; - let ghost_n = count_usize(o.ghost_n, 150.0, 0, MAX_GHOST_N as usize); - let inv_r = 1.0 / r; - dots.reserve(ghost_n); - with_fib_dirs(ghost_n, |directions| { - for d in directions { - let (px, py, z) = pt.project(d[0] * r, d[1] * r, d[2] * r); - let depth = (z * inv_r + 1.0) / 2.0; - dots.push(Dot::new(px, py, z, 0.8 * rs, 0.78).with_a(0.1 + 0.22 * depth)); - } - }); - - let ya = t * 0.24 * spin; - let ta = if face_on { - -cam_tilt - } else { - 0.55 + 0.3 * (t * 0.18).sin() * spin - }; - let ux = ya.cos(); - let uy = 0.0; - let uz = ya.sin(); - let (sta, cta) = (ta.sin(), ta.cos()); - let vx = -uz * sta; - let vy = cta; - let vz = ux * sta; - // plane normal n = u × v - let nx = uy * vz - uz * vy; - let ny = uz * vx - ux * vz; - let nz = ux * vy - uy * vx; - - let wob_mul = o.wob_mul.unwrap_or(1.0); - let wob_amp = 0.23 * wob_mul; - let base_r = if face_on { - r / (1.0 + 0.85 * wob_amp) - } else { - r - }; - - let base_lanes = o.lanes.unwrap_or(5.0).clamp(1.0, MAX_LANES); - let segs = count_usize(o.segs, 88.0, 1, MAX_SEGS as usize); - let lanes = (base_lanes * o.band_mul.unwrap_or(1.0)) - .round() - .clamp(1.0, MAX_LANES) as usize; - let r_base = o.r_base.unwrap_or(1.1); - let r_depth = o.r_depth.unwrap_or(1.7); - let seg_step = 2.0 * PI / segs as f32; - let half = (lanes as f32 - 1.0) / 2.0; - let inv_half = 1.0 / half.max(1.0); - dots.reserve(lanes.saturating_mul(segs)); - - for w in 0..lanes { - let centered = w as f32 - half; - let lane_off = centered * 0.075; - let edge = centered.abs() * inv_half; - let edge_r = 1.0 - 0.25 * edge; - let edge_ink = 0.18 * edge; - for k in 0..segs { - let a = k as f32 * seg_step; - let (ca, sa) = (a.cos(), a.sin()); - let breath = if face_on { - 0.72 + 0.28 * (t * 0.48).sin() - } else { - 1.0 - }; - let wob = (0.16 * (a * 3.0 - t * 1.7 + w as f32 * 0.22).sin() - + 0.07 * (a * 5.0 + t * 1.1).sin()) - * wob_mul - * breath; - let radial = if face_on { 1.0 + wob } else { 1.0 }; - let off = if face_on { lane_off } else { lane_off + wob }; - let x = ux * ca + vx * sa + nx * off; - let y = uy * ca + vy * sa + ny * off; - let z = uz * ca + vz * sa + nz * off; - let l = (x * x + y * y + z * z).sqrt(); - let inv_l = if l > 1e-6 { 1.0 / l } else { 0.0 }; - let rr = base_r * radial * inv_l; - let (px, py, zr) = pt.project(x * rr, y * rr, z * rr); - let depth = (zr * inv_r + 1.0) / 2.0; - dots.push( - Dot::new( - px, - py, - zr, - (r_base + r_depth * depth) * edge_r * rs, - 0.52 - 0.44 * depth + edge_ink, - ) - .with_a(0.4 + 0.6 * depth), - ); - } - } -} diff --git a/crates/orb/src/engine/web.rs b/crates/orb/src/engine/web.rs deleted file mode 100644 index 3b7ad3603c..0000000000 --- a/crates/orb/src/engine/web.rs +++ /dev/null @@ -1,120 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Web: a constellation wires itself — the "connecting" state. - -use crate::engine::{ - core::{Dot, Frame, Line, frac, hash_d, lerp, make_proj, radius_scale, vnoise, with_fib_dirs}, - profiles::{MAX_NODE_N, MAX_SIGNALS, ModeOpts, count_usize}, -}; -use std::cell::RefCell; - -thread_local! { - /// Reused between frames so the steady-state animation loop never - /// allocates. Holds `(unit_x, unit_y, unit_z, proj_x, proj_y, proj_z)` - /// per node: projecting once up front turns the O(n²) edge scan from - /// ~2n² projections into n. - static NODES: RefCell> = const { RefCell::new(Vec::new()) }; - static EDGES: RefCell> = const { RefCell::new(Vec::new()) }; -} - -pub fn draw_web_into(size: f32, t: f32, o: &ModeOpts, out: &mut Frame) { - let cx = size / 2.0; - let cy = size / 2.0; - let r = (size / 2.0) * 0.8 * o.spread.unwrap_or(1.0); - let pt = make_proj(t * 0.12, 0.32, cx, cy, r); - let rs = radius_scale(size, o.rs_pow.unwrap_or(0.6)); - - let node_n = count_usize(o.node_n, 30.0, 0, MAX_NODE_N as usize); - let thr = o.thr.unwrap_or(0.72); - let node_r = o.node_r.unwrap_or(1.4); - let node_r_depth = o.node_r_depth.unwrap_or(1.8); - let line_w = (o.line_w.unwrap_or(0.8) * rs).max(0.6); - let inv_thr = if thr > 0.0 { 1.0 / thr } else { 0.0 }; - - NODES.with(|cell| { - EDGES.with(|edges_cell| { - let mut nodes = cell.borrow_mut(); - let mut edges = edges_cell.borrow_mut(); - nodes.clear(); - edges.clear(); - nodes.reserve(node_n); - - with_fib_dirs(node_n, |directions| { - for (i, d) in directions.iter().enumerate() { - let i_f = i as f32; - let x = d[0] + 0.3 * (vnoise(i_f * 0.31 + 9.0, t * 0.24) - 0.5) * 2.0; - let y = d[1] + 0.3 * (vnoise(i_f * 0.53 + 27.0, t * 0.21) - 0.5) * 2.0; - let z = d[2] + 0.3 * (vnoise(i_f * 0.77 + 55.0, t * 0.27) - 0.5) * 2.0; - let l = (x * x + y * y + z * z).sqrt(); - let inv_l = if l > 1e-6 { 1.0 / l } else { 0.0 }; - let (ux, uy, uz) = (x * inv_l, y * inv_l, z * inv_l); - let (px, py, pz) = pt.project(ux, uy, uz); - nodes.push([ux, uy, uz, px, py, pz]); - } - }); - - for i in 0..node_n { - let a = nodes[i]; - for j in (i + 1)..node_n { - let b = nodes[j]; - let dx = a[0] - b[0]; - let dy = a[1] - b[1]; - let dz = a[2] - b[2]; - let d2 = dx * dx + dy * dy + dz * dz; - // Compare squared distances — avoids a sqrt for every - // rejected pair, and most pairs are rejected. - if d2 >= thr * thr { - continue; - } - let dist = d2.sqrt(); - let depth = ((a[5] + b[5]) / 2.0 + 1.0) / 2.0; - out.lines.push(Line { - x1: a[3], - y1: a[4], - x2: b[3], - y2: b[4], - white: 0.42, - a: (1.0 - dist * inv_thr) * (0.3 + 0.55 * depth), - w: line_w, - }); - edges.push((i, j)); - } - } - - for (i, n) in nodes.iter().enumerate() { - let depth = (n[5] + 1.0) / 2.0; - let pulse = 1.0 + 0.25 * (t * 1.4 + i as f32 * 2.7).sin(); - out.dots.push(Dot::new( - n[3], - n[4], - n[5], - (node_r + node_r_depth * depth) * pulse * rs, - 0.55 - 0.45 * depth, - )); - } - - let signals = count_usize(o.signals, 5.0, 0, MAX_SIGNALS as usize); - for s in 0..signals { - if edges.is_empty() { - break; - } - let s_f = s as f32; - let journey = t * 0.48 + s_f * 3.73; - let cycle = journey.floor(); - let edge_index = (hash_d(cycle, s_f * 4.7 + 1.3) * edges.len() as f32) - .floor() - .min((edges.len() - 1) as f32) as usize; - let (a, b) = edges[edge_index]; - let f = frac(journey); - let px = lerp(nodes[a][3], nodes[b][3], f); - let py = lerp(nodes[a][4], nodes[b][4], f); - let zr = lerp(nodes[a][5], nodes[b][5], f); - let depth = (zr + 1.0) / 2.0; - let life = (std::f32::consts::PI * f).sin().max(0.0); - out.dots.push( - Dot::new(px, py, zr, (node_r * 1.5 + node_r_depth * depth) * rs, 0.05) - .with_a(life * (0.5 + 0.5 * depth)), - ); - } - }); - }); -} diff --git a/crates/orb/src/glyph.rs b/crates/orb/src/glyph.rs new file mode 100644 index 0000000000..c8b001ce49 --- /dev/null +++ b/crates/orb/src/glyph.rs @@ -0,0 +1,1460 @@ +//! The voice glyph: the transcript's working dots (round cells tinted per row +//! from the sunrise gradient, resting dim) grown into LED matrices a voice +//! lights band by band. Pure geometry and colour, painted by the desktop +//! through GPUI and by the phone through Core Graphics. +//! +//! - [`Field`] for a call: rings are the bands, lowest at the centre. Each +//! sound starts there and is pushed out ring by ring, so the field reads as +//! the voice travelling, not as a meter. +//! - [`Beam`] for an edge or a track: a strip that rises from its bottom as +//! the voice does, the lowest band at its centre. +//! +//! Every matrix is sized at paint time to the box it fills ([`Grid::fit`]), +//! keeps the outline of what holds it ([`Outline`]), and leaves room for what +//! is drawn over it ([`Skip`]). Codex's voice takes the sunrise rows, the +//! user's the accent's, so the two are told apart by colour while moving +//! alike. [`look`] turns a dot into what to paint, the same everywhere. +use std::{collections::VecDeque, f32::consts::TAU, time::Instant}; + +/// The bands a voice is split into, lowest first. +pub const BANDS: usize = 5; +/// How loud each band is, 0–1, lowest first. +pub type Bands = [f32; BANDS]; +/// A colour as linear-ish sRGB components, 0–1. +pub type Rgb = [f32; 3]; + +/// The two voices a glyph follows. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Voice { + pub user: Bands, + pub agent: Bands, +} + +/// A dot's opacity at rest: the transcript spinner's own. +pub const REST: f32 = 0.16; + +/// Seconds a sound takes to cross one ring of a field: a ring a frame at the +/// glyph's 30 fps, the quickest a wave still shows every ring, so a voice +/// fills the field within a third of a second rather than lagging behind it. +const PUSH: f32 = 1.0 / 30.0; +/// A dissolving field: its reach in silence and what a full voice adds, and +/// the distances (in reaches) where it starts thinning and is gone. +const DISSOLVE: (f32, f32, f32, f32) = (0.5, 0.2, 1.4, 0.7); +/// The most pushes a sound takes to reach a field's edge. +const RINGS: f32 = 9.0; +/// Seconds a band takes to fall back after a sound, so a syllable's trough +/// does not blank the centre. +const RELEASE: f32 = 0.12; +/// How much of a field dot is its own band, the rest the voice's body. +const BAND_SHARE: f32 = 0.8; +/// Seconds per turn of the thinking sweep and per breath of the idle field. +const SWEEP_PERIOD: f32 = 1.6; +const BREATH_PERIOD: f32 = 3.2; +/// Seconds between a calling field's rings, and between an asking one's +/// knocks. +const RING_PERIOD: f32 = 1.4; +const KNOCK_PERIOD: f32 = 2.4; +/// Seconds a field takes to pass from one mode's pattern to the next. +const SHIFT: f32 = 0.8; +/// How much a field's slow shimmer lights its dots. +const SHIMMER: f32 = 0.09; +/// Dots dim over this many dots as they near what is drawn over a matrix. +const FEATHER: f32 = 3.0; +/// Dots dim over this many dots as they near a matrix's outline, so a +/// matrix touches what holds it and fades where it does. +const EDGE: f32 = 2.0; +/// Seconds a beam's bands take to fall back after a sound: longer than a +/// field's, so a dictation let go settles rather than drops. +const BEAM_RELEASE: f32 = 0.35; +/// Seconds a field's sense of how loud the call is takes to fall back, and +/// how much of its falloff a loud call lifts: a run of speech fills the +/// disc, a pause lets it draw back in. +const LOUD_RELEASE: f32 = 0.7; +const SWELL: f32 = 0.6; +/// Under text, the grid keeps this much of its rest presence. +const UNDER_TEXT: f32 = 0.3; +/// A dot's diameter at rest and fully lit, as shares of its slot: a +/// halftone, where light is ink coverage. At rest the dots are small and +/// the surface shows between them; lit, they swell past their slot and +/// touch, so a voice prints a solid shape with a graded edge rather than a +/// grid glowing evenly. The swell follows the square root of the level, as +/// a print's dot radius follows its coverage. +const DOT_REST: f32 = 0.58; +const DOT_SWELL: f32 = 1.18; +/// A pointer over a field lights the dots this far round it, in dots, and +/// this much at its heart. +pub const TOUCH_REACH: f32 = 7.0; +const TOUCH_LIGHT: f32 = 0.85; +/// The sunrise rows of the transcript's working spinner. +pub const SUNRISE: [Rgb; 3] = [rgb(0xB6D3EF), rgb(0xEDB185), rgb(0xF888A0)]; + +const fn rgb(hex: u32) -> Rgb { + [ + ((hex >> 16) & 0xFF) as f32 / 255.0, + ((hex >> 8) & 0xFF) as f32 / 255.0, + (hex & 0xFF) as f32 / 255.0, + ] +} + +/// What a glyph shows when no one is speaking. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The call or the microphone is opening: rings go out from the heart, + /// as a call rings out. + Connecting, + /// Live and quiet: the heart breathes, ready for a voice. + Listening, + /// Codex works: a light runs round the heart, its trail behind it. + Thinking, + /// Codex waits on the user: the rim beats twice and rests, a knock. + Asking, + /// The capture ended and is being transcribed: a highlight crosses it. + Processing, + /// The microphone is off: the dots hold still and the user's voice is + /// not shown. + Muted, +} + +/// The outline a matrix keeps: the dots outside it are left out, so a +/// matrix is as round as what holds it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Outline { + Square, + /// The largest disc in the matrix: a 5×5 loses its four corners. + Disc, + /// Round ends as high as the matrix: the shape of a pill. + Stadium, + /// Corners of this radius, in dots: the shape of a card or a screen. + Rounded(f32), + /// The bottom of something taller with corners of this radius, in dots: + /// a strip along a composer's bottom edge, square at its top. + Under(f32), +} + +impl Outline { + /// Whether the dot at `col`, `row` of a `cols` × `rows` matrix is kept: + /// its centre lies inside the outline. + pub fn keeps(self, col: f32, row: f32, cols: usize, rows: usize) -> bool { + self.depth(col, row, cols, rows) > 0.0 + } + + /// How far inside the outline the dot at `col`, `row` sits, in dots, + /// from its centre; negative outside. The outline runs through the + /// outer dots' edges, so the matrix touches what holds it. + pub fn depth(self, col: f32, row: f32, cols: usize, rows: usize) -> f32 { + self.depth_in(col, row, cols, rows, (cols as f32, rows as f32)) + } + + /// [`Self::depth`] against a box `area` dots wide and tall, centred + /// on the matrix: the box a covering matrix runs past. + pub fn depth_in(self, col: f32, row: f32, cols: usize, rows: usize, area: (f32, f32)) -> f32 { + let (hx, hy) = (area.0 / 2.0, area.1 / 2.0); + let (x, y) = (col + 0.5 - cols as f32 / 2.0, row + 0.5 - rows as f32 / 2.0); + // A box `hx` × `hy` about the centre with corners `radius` round; + // `low` alone rounds the bottom corners and keeps the top square. + let rounded = |radius: f32, low: bool| { + let radius = radius.clamp(0.0, hx.min(hy)); + let qx = x.abs() - (hx - radius); + let qy = if low { y } else { y.abs() } - (hy - radius); + if low && y < 0.0 { + // Above the middle: the plain box. + return (hx - x.abs()).min(hy + y); + } + let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); + -(outside + qx.max(qy).min(0.0) - radius) + }; + match self { + Self::Square => (hx - x.abs()).min(hy - y.abs()), + Self::Disc => hx.min(hy) - (x * x + y * y).sqrt(), + Self::Stadium => rounded(hx.min(hy), false), + Self::Rounded(radius) => rounded(radius, false), + Self::Under(radius) => rounded(radius, true), + } + } + + /// How present the dot at `col`, `row` is against the outline: full + /// inside, fading over [`EDGE`] dots to nothing where it touches. + pub fn edge(self, col: f32, row: f32, cols: usize, rows: usize) -> f32 { + smooth(self.depth(col, row, cols, rows) / EDGE) + } + + /// [`Self::edge`] against a box `area` dots wide and tall. + pub fn edge_in(self, col: f32, row: f32, cols: usize, rows: usize, area: (f32, f32)) -> f32 { + smooth(self.depth_in(col, row, cols, rows, area) / EDGE) + } +} + +/// A matrix's dots: how wide each is and the gap between them, in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Grid { + pub dot: f32, + pub gap: f32, +} + +impl Grid { + /// Dense, as an LED panel is: a gap of a third of a dot. + pub fn dense(dot: f32) -> Self { + Self { + dot, + gap: dot / 3.0, + } + } + + pub fn pitch(self) -> f32 { + self.dot + self.gap + } + + /// The size `cols` × `rows` dots take. + pub fn extent(self, cols: usize, rows: usize) -> (f32, f32) { + let span = |n: usize| n as f32 * self.pitch() - self.gap; + (span(cols), span(rows)) + } + + /// The fewest dots that cover `width` × `height`, and the (negative) + /// margin on each side once they are centred over it: the matrix runs + /// past the box, so the box's outline is what shapes it. + pub fn cover(self, width: f32, height: f32) -> ((usize, usize), (f32, f32)) { + let count = |span: f32| (((span + self.gap) / self.pitch()).ceil() as usize).max(1); + let (cols, rows) = (count(width), count(height)); + let (w, h) = self.extent(cols, rows); + ((cols, rows), ((width - w) / 2.0, (height - h) / 2.0)) + } + + /// The most dots that fit `width` × `height`, and the margin left on + /// each side once they are centred in it. + pub fn fit(self, width: f32, height: f32) -> ((usize, usize), (f32, f32)) { + let count = |span: f32| (((span + self.gap) / self.pitch()).floor() as usize).max(1); + let (cols, rows) = (count(width), count(height)); + let (w, h) = self.extent(cols, rows); + ((cols, rows), ((width - w) / 2.0, (height - h) / 2.0)) + } +} + +/// Room a matrix leaves for something drawn over it, in points from the +/// matrix's first dot. A control is a hole: no dots under it, so its own +/// fill reads cleanly. Text is soft: the grid stays, quieter, and its light +/// gives way, so the words sit in a calm patch of the same surface. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Skip { + shape: Room, + soft: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Room { + Rect { + left: f32, + top: f32, + right: f32, + bottom: f32, + radius: f32, + }, + Circle { + x: f32, + y: f32, + radius: f32, + }, +} + +impl Skip { + /// A box at `left`, `top`, `width` × `height` with corners of `radius`. + pub fn rect(left: f32, top: f32, width: f32, height: f32, radius: f32) -> Self { + Self { + shape: Room::Rect { + left, + top, + right: left + width, + bottom: top + height, + radius, + }, + soft: false, + } + } + + /// A round control of `diameter` centred at `x`, `y`. + pub fn circle(x: f32, y: f32, diameter: f32) -> Self { + Self { + shape: Room::Circle { + x, + y, + radius: diameter / 2.0, + }, + soft: false, + } + } + + /// The same room `dx`, `dy` points away. + pub fn shifted(mut self, dx: f32, dy: f32) -> Self { + self.shape = match self.shape { + Room::Rect { + left, + top, + right, + bottom, + radius, + } => Room::Rect { + left: left + dx, + top: top + dy, + right: right + dx, + bottom: bottom + dy, + radius, + }, + Room::Circle { x, y, radius } => Room::Circle { + x: x + dx, + y: y + dy, + radius, + }, + }; + self + } + + /// The room is for text: the grid stays under it, quieter. + pub fn soft(mut self) -> Self { + self.soft = true; + self + } + + /// How far, in points, `x`, `y` is from this room: below zero inside. + fn clearance(&self, x: f32, y: f32) -> f32 { + match self.shape { + Room::Rect { + left, + top, + right, + bottom, + radius, + } => { + let (cx, cy) = ((left + right) / 2.0, (top + bottom) / 2.0); + let (hx, hy) = ((right - left) / 2.0, (bottom - top) / 2.0); + let radius = radius.min(hx).min(hy); + let qx = (x - cx).abs() - (hx - radius); + let qy = (y - cy).abs() - (hy - radius); + let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); + outside + qx.max(qy).min(0.0) - radius + } + Room::Circle { + x: cx, + y: cy, + radius, + } => ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() - radius, + } + } +} + +/// How the rooms in `skip` bear on a dot centred at `x`, `y` on `grid`: +/// `None` when a hole leaves it out, else how much light it keeps and how +/// present it stays at rest. +fn rooms(skip: &[Skip], grid: Grid, x: f32, y: f32) -> Option<(f32, f32)> { + let feather = FEATHER * grid.pitch(); + let (mut light, mut presence) = (1.0f32, 1.0f32); + for room in skip { + let clear = room.clearance(x, y); + if !room.soft && clear < 0.0 { + return None; + } + let near = smooth(clear / feather); + light = light.min(near); + if room.soft { + presence = presence.min(UNDER_TEXT + (1.0 - UNDER_TEXT) * near); + } + } + Some((light, presence)) +} + +fn smooth(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// A pointer resting on a matrix, in points from its first dot: the dots +/// under it swell and light, as a halftone's do under a thumb, and the +/// light holds and fades after the pointer moves on (`strength`, 0–1). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Touch { + pub x: f32, + pub y: f32, + pub strength: f32, +} + +impl Touch { + /// How much the touch lights a dot centred at `x`, `y` on `grid`: full + /// under the pointer, easing out over [`TOUCH_REACH`]. + fn light(&self, grid: Grid, x: f32, y: f32) -> f32 { + let reach = TOUCH_REACH * grid.pitch(); + let away = ((x - self.x).powi(2) + (y - self.y).powi(2)).sqrt() / reach; + TOUCH_LIGHT * self.strength.clamp(0.0, 1.0) * smooth(1.0 - away) + } + + /// The strength `held` seconds after the pointer last moved: it holds, + /// then fades, as a lit cell does behind a cursor. + pub fn strength_after(held: f32) -> f32 { + const HOLD: f32 = 0.4; + const FADE: f32 = 0.8; + 1.0 - ((held - HOLD) / FADE).clamp(0.0, 1.0) + } +} + +/// The modes a glyph has shown, oldest first, and when each began: a glyph +/// passes from one pattern to the next over [`SHIFT`], and a mode that +/// arrives mid-passage joins the blend rather than cutting it, so quick +/// successive changes stay continuous. +#[derive(Clone, Debug)] +struct Passage { + shown: Vec<(Mode, f32)>, +} + +impl Passage { + fn new() -> Self { + Self { + shown: vec![(Mode::Muted, f32::NEG_INFINITY)], + } + } + + /// The mode shown now, once fully arrived. + fn mode(&self) -> Mode { + self.shown.last().map_or(Mode::Muted, |(mode, _)| *mode) + } + + /// Whether any mode has been shown yet. + fn begun(&self) -> bool { + self.shown.len() > 1 || self.shown[0].1.is_finite() + } + + fn shift(&mut self, mode: Mode, time: f32) { + if self.begun() && self.mode() == mode { + return; + } + if !self.begun() { + self.shown[0] = (mode, time - SHIFT); + return; + } + // Layers fully covered by a later one are gone. + let settled = self + .shown + .iter() + .rposition(|(_, since)| time - since >= SHIFT) + .unwrap_or(0); + self.shown.drain(..settled); + self.shown.push((mode, time)); + } + + /// How far the newest mode has arrived, eased: 1 once there. + fn arrived(&self, time: f32) -> f32 { + self.shown + .last() + .map_or(1.0, |(_, since)| smooth((time - since) / SHIFT)) + } + + /// `pattern` of every mode shown, each later one laid over the earlier + /// as far as it has arrived. + fn blend(&self, time: f32, pattern: impl Fn(Mode) -> f32) -> f32 { + let mut value = pattern(self.shown[0].0); + for (mode, since) in &self.shown[1..] { + let weight = smooth((time - since) / SHIFT); + value += (pattern(*mode) - value) * weight; + } + value + } +} + +/// The glyph's two sets of row tints, top row first. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Inks { + pub agent: [Rgb; 3], + pub user: [Rgb; 3], +} + +impl Inks { + /// Codex in sunrise, the user in the accent's glyph rows `accent`, + /// lifted: the accent's deep row is near the page's own darkness. + pub fn new(accent: [Rgb; 3]) -> Self { + Self { + agent: SUNRISE, + user: accent.map(|tint| mix(tint, [1.0; 3], 0.22)), + } + } +} + +fn mix(from: Rgb, to: Rgb, t: f32) -> Rgb { + let t = t.clamp(0.0, 1.0); + [0, 1, 2].map(|i| from[i] + (to[i] - from[i]) * t) +} + +/// The tint of the dot at `col`, `row`: one of the palette's three, scattered +/// over the grid at random and fixed to the dot, so the colours mingle as a +/// print's inks do and never move. A dot is ever one of its palette's +/// colours, never a blend between them. +fn tint(rows: [Rgb; 3], col: f32, row: f32) -> Rgb { + // A small integer hash of the dot's place: even over the grid, the same + // every frame. + let mut h = (col as i32 as u32).wrapping_mul(0x9E37_79B1) + ^ (row as i32 as u32).wrapping_mul(0x85EB_CA6B); + h ^= h >> 15; + h = h.wrapping_mul(0x2C1B_3C6D); + h ^= h >> 12; + rows[(h % 3) as usize] +} + +/// Whose ink a dot takes when both voices light it: the louder's, whole. +fn ink(agent: Rgb, user: Rgb, share: f32) -> Rgb { + if share >= 0.5 { user } else { agent } +} + +/// A band's level `at` 0 (lowest) to 1 (highest), between bands. +fn band(bands: &Bands, at: f32) -> f32 { + let at = at.clamp(0.0, 1.0) * (BANDS - 1) as f32; + let low = at.floor() as usize; + let high = (low + 1).min(BANDS - 1); + bands[low] + (bands[high] - bands[low]) * (at - low as f32) +} + +fn body(bands: &Bands) -> f32 { + bands.iter().sum::() / BANDS as f32 +} + +/// A voice's level as a dot shows it: the hush below the first few percent +/// stays at rest, and the rest rises steeply, so a voice reads as dots +/// lighting up rather than a grid glowing evenly. +fn shape(level: f32) -> f32 { + smooth((level - 0.06) / 0.84) +} + +/// One dot: where it sits in dots, how lit it is (0 at rest, 1 full), its +/// colour, and how present it is at rest (lower under text). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Cell { + pub col: f32, + pub row: f32, + pub level: f32, + pub rgb: Rgb, + pub presence: f32, +} + +/// What lit a dot: each voice's share and the mode's own motion. +fn lit(user: f32, agent: f32, idle: f32, inks: &Inks, (col, row): (f32, f32)) -> (f32, Rgb) { + let (user, agent) = (shape(user), shape(agent)); + let voice = user.max(agent); + let level = voice.max(idle).clamp(0.0, 1.0); + let share = if voice > 0.02 { + user / (user + agent) + } else { + 0.0 + }; + ( + level, + ink(tint(inks.agent, col, row), tint(inks.user, col, row), share), + ) +} + +/// The level a mode gives a field's dot `out` from its heart (0) to its +/// reach (1), `turn` round it (0–1, clockwise from the top), at `time`. Each +/// pattern says what the call is doing, and all are smooth in space, as a +/// voice is, so one passes into the next without a seam. A dot only starts +/// to light above a few hundredths ([`shape`]): what must stay visible at +/// rest sits just over that, well under a voice. +fn calm(mode: Mode, out: f32, turn: f32, time: f32) -> f32 { + let bell = |d: f32, width: f32| (-(d / width) * (d / width)).exp(); + // A slow shimmer drifting across the field, so it reads as a surface + // that lives rather than a grid that waits; still while muted. + let shimmer = if mode == Mode::Muted { + 0.0 + } else { + let (x, y) = (out * (turn * TAU).sin(), out * (turn * TAU).cos()); + let drift = ((x * 4.1 + time * 0.37).sin() * (y * 3.7 - time * 0.29).cos() + 1.0) / 2.0; + SHIMMER * drift * (1.0 - out * out).max(0.0) + }; + shimmer + + match mode { + // Calling: rings leave the heart and fade as they spread. + Mode::Connecting => (0..2) + .map(|k| { + let at = (time / RING_PERIOD + k as f32 * 0.5).fract(); + 0.6 * (1.0 - at) * bell(out - at, 0.1) + }) + .fold(0.0, f32::max), + // Ready: the heart breathes, the swell easing out from it. + // Ready: the heart breathes over a floor of light that never goes + // out, so a live call at rest is never mistaken for a muted one. + Mode::Listening => { + let breath = 0.5 - 0.5 * ((time / BREATH_PERIOD - out * 0.35) * TAU).cos(); + (0.12 + 0.24 * breath) * (1.0 - out * out).max(0.0) + } + // Working: a light runs round the heart, its trail fading behind + // it, and the heart itself breathes slowly under it. + Mode::Thinking => { + // Its head is soft: a dot ahead of it lights as it nears, + // never in one frame. + let behind = (time / SWEEP_PERIOD - turn).rem_euclid(1.0); + let head = (-behind * 3.0).exp().max(bell(1.0 - behind, 0.06)); + let comet = 0.9 * head * bell(out - 0.6, 0.22); + let heart = 0.12 + 0.08 * (0.5 - 0.5 * (time / BREATH_PERIOD * TAU).cos()); + comet + heart * bell(out, 0.32) + } + // Your turn: a ring at the rim holds its glow and beats twice, + // then rests, like a knock at a door. + Mode::Asking => { + let at = (time / KNOCK_PERIOD).fract(); + let knock = bell(at - 0.08, 0.07).max(0.7 * bell(at - 0.28, 0.07)); + let ring = bell(out - 0.78, 0.16 + 0.06 * knock); + (0.3 + 0.55 * knock) * ring + 0.06 * smooth((out - 0.15) / 0.7) + } + Mode::Processing | Mode::Muted => 0.0, + } +} + +/// Each band rises with the voice at once and falls back over [`RELEASE`]. +fn hold(held: &mut Voice, voice: &Voice, dt: f32) { + hold_over(held, voice, dt, RELEASE); +} + +/// Keeps `voice` in `held`, each band falling back over `release` seconds. +fn hold_over(held: &mut Voice, voice: &Voice, dt: f32, release: f32) { + let fall = (-dt / release).exp(); + for (held, heard) in [ + (&mut held.user, &voice.user), + (&mut held.agent, &voice.agent), + ] { + for (held, heard) in held.iter_mut().zip(heard) { + *held = heard.max(*held * fall); + } + } +} + +/// Seconds since the last call, at most a quarter: a stalled frame does not +/// leap. +fn step(at: &mut Option, now: Instant) -> f32 { + at.replace(now) + .map_or(0.0, |at| now.saturating_duration_since(at).as_secs_f32()) + .min(0.25) +} + +/// How a field's rings run. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Rings { + /// Round, from the centre: a call's disc. + Round, + /// Concentric with a rounded rectangle of the field's own size, its + /// corners `radius` dots round: on a phone, rings the shape of the + /// screen, its heart a line down the middle. + Screen(f32), +} + +/// A call's field: the last voices heard, newest first, one per [`PUSH`]. +#[derive(Clone)] +pub struct Field { + rings: Rings, + /// How much of a sound is lost by the field's reach. + falloff: f32, + /// No edge: the grid thins out with distance from the heart, and a + /// louder voice carries it further. + dissolve: bool, + /// The dots near the outline go quiet, so the field eases into what + /// holds it rather than stopping at a hard ring. + soft_rim: bool, + /// How present the dots are at rest, against the surface they sit on: + /// quieter where that surface is darker and the grid would show. + rest: f32, + heard: VecDeque, + held: Voice, + /// How loud the call has been lately: it rises with a voice at once and + /// falls back slowly, so sustained speech carries the field further out + /// than a word does. + loud: f32, + at: Option, + owed: f32, + time: f32, + /// The modes shown, passing from each to the next. + passage: Passage, +} + +impl Field { + pub fn new(rings: Rings) -> Self { + Self { + rings, + falloff: 0.35, + dissolve: false, + soft_rim: false, + rest: 1.0, + heard: VecDeque::new(), + held: Voice::default(), + loud: 0.0, + at: None, + owed: 0.0, + time: 0.0, + passage: Passage::new(), + } + } + + /// Shows `mode` from now on, passing into it from the pattern shown over + /// [`SHIFT`] rather than cutting to it. + pub fn shift(&mut self, mode: Mode) { + self.passage.shift(mode, self.time); + } + + /// How far the field has passed into `mode`, eased: 1 once there. + fn shifted(&self, mode: Mode) -> f32 { + if self.passage.mode() != mode { + return 1.0; + } + self.passage.arrived(self.time) + } + + /// Loses `share` of a sound by the field's reach: near 1, only its heart + /// lights. + pub fn soft_rimmed(mut self) -> Self { + self.soft_rim = true; + self + } + + pub fn dissolving(mut self) -> Self { + self.dissolve = true; + self + } + + /// Keeps `share` of the dots' presence at rest: on a dark surface the + /// unlit grid would otherwise read as a mesh. + pub fn resting(mut self, share: f32) -> Self { + self.rest = share.clamp(0.0, 1.0); + self + } + + pub fn falling(mut self, share: f32) -> Self { + self.falloff = share.clamp(0.0, 1.0); + self + } + + /// Runs the rings `rings` from now on: a screen's follow its corners. + pub fn set_rings(&mut self, rings: Rings) { + self.rings = rings; + } + + /// Hears `voice` at `now`; the field keeps what has not yet left it. + pub fn hear(&mut self, voice: Voice, now: Instant) { + let dt = step(&mut self.at, now); + self.time += dt; + self.owed += dt; + hold(&mut self.held, &voice, dt); + let voice = self.held; + let level = body(&voice.user).max(body(&voice.agent)); + self.loud = level.max(self.loud * (-dt / LOUD_RELEASE).exp()); + if self.heard.is_empty() { + self.heard.push_front(voice); + } + while self.owed >= PUSH { + self.owed -= PUSH; + self.heard.push_front(voice); + } + // The newest sound replaces the one being pushed out this step. + self.heard[0] = voice; + self.heard.truncate(RINGS as usize * 3); + } + + /// The voice `ring` pushes out from the heart, between the two rings it + /// falls between. + fn heard_at(&self, ring: f32) -> Voice { + let at = |i: usize| self.heard.get(i).copied().unwrap_or_default(); + let (near, t) = (ring.floor() as usize, ring.fract()); + let (a, b) = (at(near), at(near + 1)); + let blend = |a: &Bands, b: &Bands| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t); + Voice { + user: blend(&a.user, &b.user), + agent: blend(&a.agent, &b.agent), + } + } + + /// Whether anything is still moving: a sound in the field or a mode that + /// animates on its own. + pub fn moving(&self, mode: Mode) -> bool { + mode != Mode::Muted && mode != Mode::Processing + || self.shifted(mode) < 1.0 + || self + .held + .user + .iter() + .chain(&self.held.agent) + .any(|level| *level > 0.01) + } + + /// How far out a dot `x`, `y` dots from the centre of a `cols` × `rows` + /// field sits, 0 at its heart, 1 at its reach. + fn reach(&self, x: f32, y: f32, cols: f32, rows: f32) -> f32 { + let (hx, hy) = (((cols - 1.0) / 2.0).max(1.0), ((rows - 1.0) / 2.0).max(1.0)); + match self.rings { + Rings::Round => (x * x + y * y).sqrt() / hx.min(hy), + Rings::Screen(radius) => { + let radius = radius.min(hx).min(hy); + let qx = x.abs() - (hx - radius); + let qy = y.abs() - (hy - radius); + let outside = (qx.max(0.0).powi(2) + qy.max(0.0).powi(2)).sqrt(); + let edge = outside + qx.max(qy).min(0.0) - radius; + 1.0 + edge / hx.min(hy) + } + } + } + + /// The field's dots across `cols` × `rows` on `grid`, inside `outline` + /// and around `skip`, for `mode`; the user's voice is left out while + /// muted. + #[allow(clippy::too_many_arguments)] + pub fn cells( + &self, + cols: usize, + rows: usize, + grid: Grid, + mode: Mode, + inks: &Inks, + outline: Outline, + skip: &[Skip], + ) -> Vec { + self.cells_touched(cols, rows, grid, mode, inks, outline, skip, None) + } + + /// [`Self::cells`] with a pointer `touch` resting on the field. + #[allow(clippy::too_many_arguments)] + pub fn cells_touched( + &self, + cols: usize, + rows: usize, + grid: Grid, + mode: Mode, + inks: &Inks, + outline: Outline, + skip: &[Skip], + touch: Option, + ) -> Vec { + let (fc, fr) = (cols as f32, rows as f32); + let (cx, cy) = ((fc - 1.0) / 2.0, (fr - 1.0) / 2.0); + let rings = cx.max(cy).min(RINGS); + // A dissolving field reaches further the louder it is heard. + let reach = if self.dissolve { + let loud = body(&self.held.user).max(body(&self.held.agent)); + DISSOLVE.0 + DISSOLVE.1 * shape(loud) + } else { + 1.0 + }; + let shifted = self.shifted(mode); + // Sustained speech carries further: the field loses less of a voice + // by its reach the louder the call has lately been, so a run of + // words fills the disc where a single one lights its heart. + let falloff = self.falloff * (1.0 - SWELL * shape(self.loud)); + let mut cells = Vec::with_capacity(cols * rows); + for row in 0..rows { + for col in 0..cols { + let (c, r) = (col as f32, row as f32); + let edge = outline.edge(c, r, cols, rows); + if edge <= 0.0 { + continue; + } + let centre = ( + c * grid.pitch() + grid.dot / 2.0, + r * grid.pitch() + grid.dot / 2.0, + ); + let Some((room, presence)) = rooms(skip, grid, centre.0, centre.1) else { + continue; + }; + let (x, y) = (c - cx, r - cy); + let out = self.reach(x, y, fc, fr).max(0.0) / reach; + // Past its reach a dissolving field thins out to nothing, so + // it has no edge against what is behind it. + let thin = if self.dissolve { + smooth((DISSOLVE.2 - out) / (DISSOLVE.2 - DISSOLVE.3)) + } else { + 1.0 + }; + if thin <= 0.0 { + continue; + } + let rim = if self.soft_rim { + 0.65 + 0.35 * smooth((1.0 - out * reach) / 0.2) + } else { + 1.0 + }; + // A sound reaches this ring this many pushes after the heart, + // weakening as it spreads and dying out past the reach. A dot + // between two rings hears both, so the field is as smooth + // in space as the voice is in time. + let voice = self.heard_at(out * rings); + let fade = if out > 1.0 { + ((1.6 - out) / 0.6).clamp(0.0, 1.0) + } else { + 1.0 + }; + let spread = (1.0 - falloff * out.min(1.0).powi(2)) * fade * room; + let carry = |bands: &Bands| { + (BAND_SHARE * band(bands, out) + (1.0 - BAND_SHARE) * body(bands)) * spread + }; + let user = if mode == Mode::Muted { + 0.0 + } else { + carry(&voice.user) + }; + let agent = carry(&voice.agent); + // The mode's own pattern, passing over from the last one's. + let turn = (x / cx.max(1.0)).atan2(-y / cy.max(1.0)).rem_euclid(TAU) / TAU; + let pattern = |mode| calm(mode, out.min(1.0), turn, self.time); + let calm = if shifted < 1.0 { + self.passage.blend(self.time, pattern) + } else { + pattern(mode) + } * fade.max(0.3) + * room; + let (level, rgb) = lit(user, agent, calm, inks, (c, r)); + // The pointer's light adds to the voice's and brings the + // dots under it fully present, whatever their rest. + let touched = touch.map_or(0.0, |touch| touch.light(grid, centre.0, centre.1)); + let presence = presence * thin * rim * edge * self.rest; + cells.push(Cell { + col: c, + row: r, + level: (level + touched).min(1.0) * thin * edge, + rgb, + presence: presence.max(touched * thin * edge), + }); + } + } + cells + } +} + +/// The level a mode gives a beam's column `x` across it (-1 to 1) at +/// `time`: a field's patterns, told along a line. +fn calm_along(mode: Mode, x: f32, time: f32) -> f32 { + let bell = |d: f32, width: f32| (-(d / width) * (d / width)).exp(); + let out = x.abs(); + match mode { + // Calling: pulses leave the centre for both ends. + Mode::Connecting => (0..2) + .map(|k| { + let at = (time / RING_PERIOD + k as f32 * 0.5).fract(); + 0.55 * (1.0 - 0.6 * at) * bell(out - at, 0.14) + }) + .fold(0.0, f32::max), + // Ready: the centre breathes. + Mode::Listening => { + let breath = 0.5 - 0.5 * ((time / BREATH_PERIOD - out * 0.35) * TAU).cos(); + 0.1 + 0.12 * breath * (1.0 - out * out) + } + // Working: a light runs from side to side. + Mode::Thinking => 0.55 * bell(x - (time / SWEEP_PERIOD * TAU).sin() * 0.7, 0.35), + // Your turn: both ends beat twice, then rest. + Mode::Asking => { + let at = (time / KNOCK_PERIOD).fract(); + let knock = bell(at - 0.08, 0.05).max(0.7 * bell(at - 0.24, 0.05)); + 0.04 + 0.45 * knock * smooth((out - 0.2) / 0.7) + } + // Transcribing: a highlight crosses, end to end. + Mode::Processing => { + 0.55 * bell(x - ((time / SWEEP_PERIOD).fract() * 2.0 - 1.0) * 1.2, 0.35) + } + Mode::Muted => 0.04, + } +} + +/// An edge's beam: the voice as it is now, held through its troughs. +#[derive(Clone)] +pub struct Beam { + held: Voice, + at: Option, + time: f32, + /// How present the dots are at rest, against the surface they sit on. + rest: f32, + /// The modes shown, passing from each to the next. + passage: Passage, +} + +impl Default for Beam { + fn default() -> Self { + Self { + held: Voice::default(), + at: None, + time: 0.0, + rest: 1.0, + passage: Passage::new(), + } + } +} + +impl Beam { + pub fn hear(&mut self, voice: Voice, now: Instant) { + let dt = step(&mut self.at, now); + self.time += dt; + hold_over(&mut self.held, &voice, dt, BEAM_RELEASE); + } + + /// Shows `mode` from now on, passing into it over [`SHIFT`]. + pub fn shift(&mut self, mode: Mode) { + self.passage.shift(mode, self.time); + } + + /// Keeps `share` of the dots' presence at rest: over the page, with no + /// surface of its own, the unlit beam would otherwise read as a strip. + pub fn resting(mut self, share: f32) -> Self { + self.rest = share.clamp(0.0, 1.0); + self + } + + /// Whether anything is still moving: a sound in the beam, a passage + /// between modes, or a mode that animates on its own. + pub fn moving(&self, mode: Mode) -> bool { + mode != Mode::Muted + || (self.passage.mode() == mode && self.passage.arrived(self.time) < 1.0) + || self + .held + .user + .iter() + .chain(&self.held.agent) + .any(|level| *level > 0.01) + } + + /// The beam's dots across `cols` × `rows`, inside `outline`, rising from + /// the bottom row: the lowest band at the centre, the highest at the + /// ends. Quiet, it shows its mode's pattern ([`calm_along`]). + pub fn cells( + &self, + cols: usize, + rows: usize, + mode: Mode, + inks: &Inks, + outline: Outline, + ) -> Vec { + self.cells_over(cols, rows, (cols as f32, rows as f32), mode, inks, outline) + } + + /// [`Self::cells`] for a matrix covering a box `area` dots wide and + /// tall ([`Grid::cover`]): the box's outline, not the matrix's, is where + /// the beam fades out, so it fills the box to its curves. + pub fn cells_over( + &self, + cols: usize, + rows: usize, + area: (f32, f32), + mode: Mode, + inks: &Inks, + outline: Outline, + ) -> Vec { + let centre = (cols as f32 - 1.0) / 2.0; + let mut cells = Vec::with_capacity(cols * rows); + let shifted = if self.passage.mode() == mode { + self.passage.arrived(self.time) + } else { + 1.0 + }; + for col in 0..cols { + let x = (col as f32 - centre) / centre.max(1.0); + let out = x.abs(); + // Bells around the centre, narrower the higher the band. + let reach = |bands: &Bands| { + shape(band(bands, out) * 0.7 + body(bands) * 0.5) * (1.0 - out * out * 0.6) + }; + let user = if mode == Mode::Muted { + 0.0 + } else { + reach(&self.held.user) + }; + let agent = reach(&self.held.agent); + let calm = if shifted < 1.0 { + self.passage + .blend(self.time, |mode| calm_along(mode, x, self.time)) + } else { + calm_along(mode, x, self.time) + }; + let height = user.max(agent).max(calm); + let share = if user + agent > 0.02 { + user / (user + agent) + } else { + 0.0 + }; + for row in 0..rows { + let edge = outline.edge_in(col as f32, row as f32, cols, rows, area); + if edge <= 0.0 { + continue; + } + // Up from the bottom: the top of the column fades out. + let up = (rows - 1 - row) as f32 / rows as f32; + // A full voice reaches the top row: the strip is the beam's, + // with no quiet band left above it. + let level = + ((height * 1.15 - up) * rows as f32 * 0.6).clamp(0.0, 1.0) * height.sqrt(); + let (c, r) = (col as f32, row as f32); + cells.push(Cell { + col: c, + row: r, + level: level * edge, + rgb: ink(tint(inks.agent, c, r), tint(inks.user, c, r), share), + presence: edge * self.rest, + }); + } + } + cells + } +} + +/// What to paint for a dot, the same on every platform: a filled circle, +/// nothing else. Light is told by colour, not glow: a dot at rest is a quiet, +/// greyed version of its row's tint; lit, it is saturated and bright. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + /// The dot's colour and opacity. + pub fill: Rgb, + pub alpha: f32, + /// Its diameter as a share of the grid's dot. + pub size: f32, +} + +/// How `cell` is painted: its ink as it is, one of its palette's colours, +/// at an opacity and a size that tell its light. Nothing whitens or greys +/// it, so a working Codex is the transcript's 3×3 glyph writ large. +pub fn look(cell: &Cell) -> Look { + let level = cell.level.clamp(0.0, 1.0); + Look { + fill: cell.rgb, + alpha: (REST + (1.0 - REST) * level) * (cell.presence + (1.0 - cell.presence) * level), + size: DOT_REST + (DOT_SWELL - DOT_REST) * level.sqrt(), + } +} + +/// A voice of which only the loudness is known, split by how fast that +/// loudness moves: the slow bands carry the phrase, the middle one the +/// level itself, the fast ones each syllable's attack and its flutter — +/// heavy to fine, as a spectrum's bands run from low to high. Codex's voice +/// is only ever known this way; the user's is, wherever the microphone's own +/// spectrum is not to hand. +#[derive(Default)] +pub struct Rhythm { + phrase: f32, + syllable: f32, + quick: f32, + /// How much the level has lately swung about its syllable follower: + /// the attack and flutter bands are read against it, so fast, even + /// speech, whose swings are small, still moves them. + swing: f32, + at: Option, + bands: Bands, +} + +/// The peak, in decibels, that reads as silence and the one that fills the +/// scale: full scale itself, so loud, continuous speech (whose peaks sit +/// well above -8 dB) still rises and falls instead of pinning at the top. +const SCALE: (f32, f32) = (-46.0, 0.0); +/// Seconds over which a phrase, a syllable and an attack are told apart. +const PACE: (f32, f32, f32) = (0.45, 0.14, 0.05); +/// Seconds over which the swing is averaged, the swing a plain word makes, +/// and how far the fast bands may be lifted when the swing is small. +const SWING: (f32, f32, f32) = (1.0, 0.1, 4.0); + +impl Rhythm { + /// Hears a peak, 0–1, at `now`. + pub fn hear(&mut self, peak: f32, now: Instant) { + let loud = 20.0 * peak.max(1e-6).log10(); + let level = ((loud - SCALE.0) / (SCALE.1 - SCALE.0)).clamp(0.0, 1.0); + let dt = step(&mut self.at, now); + for (follower, pace) in [ + (&mut self.phrase, PACE.0), + (&mut self.syllable, PACE.1), + (&mut self.quick, PACE.2), + ] { + *follower += (level - *follower) * (1.0 - (-dt / pace).exp()); + } + // The phrase is the heart of a field: it rises with the voice at + // once and only its fall is slow, so a word lights the heart as it + // is said and the heart holds through the troughs between them. + self.phrase = self.phrase.max(level); + // A run of quick words swings less than slow ones: the fast bands are + // read against the swing's own size, so they keep moving at speed + // and do not blow up in silence. + let swing = (level - self.syllable).abs(); + self.swing += (swing - self.swing) * (1.0 - (-dt / SWING.0).exp()); + let gain = if level > 0.1 { + (SWING.1 / self.swing.max(1e-3)).clamp(1.0, SWING.2) + } else { + 1.0 + }; + // The syllable's beat against the swing's own size: a full pulse + // whether the speech is slow and wide or fast and even, so the heart + // keeps time with the words instead of settling at their loudness. + let beat = if level > 0.1 { + (0.5 + 0.5 * (level - self.syllable) / self.swing.max(0.02)).clamp(0.0, 1.0) + } else { + 0.5 + }; + self.bands = [ + self.phrase * (0.5 + 0.5 * beat), + self.syllable * (0.35 + 0.65 * beat), + level, + ((level - self.syllable) * 2.5 * gain).clamp(0.0, 1.0), + ((level - self.quick).abs() * 4.0 * gain).clamp(0.0, 1.0), + ]; + } + + pub fn bands(&self) -> Bands { + self.bands + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + fn inks() -> Inks { + Inks { + agent: [[1.0, 0.0, 0.0]; 3], + user: [[0.0, 0.0, 1.0]; 3], + } + } + + fn at(cells: &[Cell], row: usize, col: usize) -> Cell { + *cells + .iter() + .find(|cell| cell.row == row as f32 && cell.col == col as f32) + .expect("a kept dot") + } + + const GRID: Grid = Grid { dot: 6.0, gap: 2.0 }; + + #[test] + fn a_sound_starts_at_the_centre_and_is_pushed_out_ring_by_ring() { + let start = Instant::now(); + let mut field = Field::new(Rings::Round); + let (quiet, loud) = ( + Voice::default(), + Voice { + user: [0.0; BANDS], + agent: [1.0; BANDS], + }, + ); + field.hear(quiet, start); + field.hear(quiet, start + Duration::from_millis(200)); + field.hear(loud, start + Duration::from_millis(210)); + let cells = + |field: &Field| field.cells(9, 9, GRID, Mode::Processing, &inks(), Outline::Disc, &[]); + let now = cells(&field); + assert!(at(&now, 4, 4).level > 0.6, "centre lit at once"); + assert!(at(&now, 4, 8).level < 0.05, "edge not yet"); + field.hear(loud, start + Duration::from_millis(210 + 6 * 70)); + let later = cells(&field); + assert!(at(&later, 4, 7).level > 0.3, "edge reached"); + assert!( + at(&later, 4, 8).level < at(&later, 4, 7).level, + "the outer dot fades where it touches the outline" + ); + assert_eq!( + at(&later, 4, 4).rgb, + [1.0, 0.0, 0.0], + "Codex in its own ink" + ); + } + + #[test] + fn the_user_is_told_apart_by_colour_and_hidden_while_muted() { + let mut field = Field::new(Rings::Round); + field.hear( + Voice { + user: [0.9; BANDS], + agent: [0.0; BANDS], + }, + Instant::now(), + ); + let cells = field.cells(5, 5, GRID, Mode::Listening, &inks(), Outline::Square, &[]); + assert!(at(&cells, 2, 2).level > 0.6); + assert_eq!(at(&cells, 2, 2).rgb, [0.0, 0.0, 1.0]); + let muted = field.cells(5, 5, GRID, Mode::Muted, &inks(), Outline::Square, &[]); + assert!(muted.iter().all(|cell| cell.level < 0.01)); + } + + #[test] + fn a_matrix_takes_the_outline_of_what_holds_it() { + let kept = |outline: Outline, cols, rows| { + (0..rows) + .flat_map(|row| (0..cols).map(move |col| (col, row))) + .filter(|&(col, row)| outline.keeps(col as f32, row as f32, cols, rows)) + .count() + }; + // A 5×5 disc is the 21-dot LED circle. + assert_eq!(kept(Outline::Disc, 5, 5), 21); + // A pill keeps its straight middle and rounds both ends alike. + let pill = |col: usize, row: usize| Outline::Stadium.keeps(col as f32, row as f32, 20, 5); + assert!(pill(10, 0) && pill(10, 4) && pill(0, 2) && pill(19, 2)); + assert!(!pill(0, 0) && !pill(19, 0) && !pill(0, 4) && !pill(19, 4)); + // A card's corners are cut as round as the card. + assert!(!Outline::Rounded(3.0).keeps(0.0, 0.0, 30, 10)); + assert!(Outline::Rounded(3.0).keeps(3.0, 0.0, 30, 10)); + // A strip under a card keeps its top corners and rounds its bottom. + assert!(Outline::Under(3.0).keeps(0.0, 0.0, 60, 4)); + assert!(!Outline::Under(3.0).keeps(0.0, 3.0, 60, 4)); + } + + #[test] + fn a_dissolving_field_has_no_edge_and_a_voice_carries_it_further() { + let mut field = Field::new(Rings::Round).dissolving(); + let count = |field: &Field| { + field + .cells(41, 41, GRID, Mode::Listening, &inks(), Outline::Square, &[]) + .iter() + .filter(|cell| cell.presence > 0.05) + .count() + }; + let quiet = count(&field); + let cells = field.cells(41, 41, GRID, Mode::Listening, &inks(), Outline::Square, &[]); + assert!( + !cells.iter().any(|cell| cell.row == 0.0 && cell.col == 0.0), + "the corners are gone" + ); + let edge = cells + .iter() + .filter(|cell| cell.row == 20.0) + .map(|cell| cell.presence); + assert!(edge.fold(1.0f32, f32::min) < 0.2, "it thins out, no edge"); + field.hear( + Voice { + user: [0.0; BANDS], + agent: [1.0; BANDS], + }, + Instant::now(), + ); + assert!(count(&field) > quiet, "a voice carries it further"); + } + + #[test] + fn controls_are_holes_and_text_is_a_quiet_patch() { + let mut field = Field::new(Rings::Screen(4.0)); + field.hear( + Voice { + user: [0.0; BANDS], + agent: [1.0; BANDS], + }, + Instant::now(), + ); + let button = Skip::circle(80.0, 200.0, 64.0); + let words = Skip::rect(16.0, 40.0, 120.0, 40.0, 8.0).soft(); + let cells = field.cells( + 20, + 40, + GRID, + Mode::Listening, + &inks(), + Outline::Square, + &[button, words], + ); + let centre = |cell: &Cell| (cell.col * 8.0 + 3.0, cell.row * 8.0 + 3.0); + // No dot under the button. + assert!(cells.iter().all(|cell| { + let (x, y) = centre(cell); + ((x - 80.0).powi(2) + (y - 200.0).powi(2)).sqrt() >= 32.0 + })); + // Under the words the grid stays, quiet and unlit. + let under: Vec<_> = cells + .iter() + .filter(|cell| { + let (x, y) = centre(cell); + (30.0..120.0).contains(&x) && (50.0..70.0).contains(&y) + }) + .collect(); + assert!(!under.is_empty()); + assert!( + under + .iter() + .all(|cell| cell.level < 0.05 && cell.presence < 0.5) + ); + } + + #[test] + fn a_grid_fills_its_box_and_centres_what_is_left() { + let ((cols, rows), (mx, my)) = Grid::dense(3.0).fit(100.0, 20.0); + assert_eq!((cols, rows), (25, 5)); + assert!((0.0..4.0).contains(&mx) && (0.0..4.0).contains(&my)); + } + + #[test] + fn a_voice_known_only_by_its_loudness_is_split_by_its_rhythm() { + let start = Instant::now(); + let mut rhythm = Rhythm::default(); + let mut attack = 0.0f32; + for tick in 0..100u32 { + let t = tick as f32 * 0.04; + let peak = 0.004 + 0.3 * (t * 5.0 * TAU).sin().max(0.0); + rhythm.hear(peak, start + Duration::from_millis(u64::from(tick) * 40)); + attack = attack.max(rhythm.bands()[3]); + assert!(tick < 30 || rhythm.bands()[0] > 0.2, "{:?}", rhythm.bands()); + } + assert!(attack > 0.5, "{attack}"); + for tick in 100..150u32 { + rhythm.hear(0.0, start + Duration::from_millis(u64::from(tick) * 40)); + } + assert!(rhythm.bands().iter().all(|band| *band < 0.02)); + } + + #[test] + fn a_mode_arriving_mid_passage_joins_the_blend_without_a_jump() { + let inks = Inks::new(SUNRISE); + let start = Instant::now(); + let mut field = Field::new(Rings::Round); + let step = |field: &mut Field, ms: u64| { + field.hear(Voice::default(), start + Duration::from_millis(ms)) + }; + step(&mut field, 0); + field.shift(Mode::Thinking); + step(&mut field, 300); + // Halfway into Thinking, Asking arrives: the next frame continues + // from the blend as it stood, rather than cutting to a new passage. + let before = field.cells(21, 21, GRID, Mode::Thinking, &inks, Outline::Disc, &[]); + field.shift(Mode::Asking); + step(&mut field, 316); + let after = field.cells(21, 21, GRID, Mode::Asking, &inks, Outline::Disc, &[]); + let jump = before + .iter() + .zip(&after) + .map(|(a, b)| (a.level - b.level).abs()) + .fold(0.0f32, f32::max); + assert!(jump < 0.3, "a frame's change stays small: {jump}"); + // Once the passage is over, Asking alone shows (a frame at a time: + // a stalled frame never leaps). + for ms in (350..=2000).step_by(50) { + step(&mut field, ms); + } + let settled = field.cells(21, 21, GRID, Mode::Asking, &inks, Outline::Disc, &[]); + let mut plain = Field::new(Rings::Round); + step(&mut plain, 0); + plain.shift(Mode::Asking); + step(&mut plain, 300); + step(&mut plain, 316); + for ms in (350..=2000).step_by(50) { + step(&mut plain, ms); + } + let same = plain.cells(21, 21, GRID, Mode::Asking, &inks, Outline::Disc, &[]); + for (a, b) in settled.iter().zip(&same) { + assert!((a.level - b.level).abs() < 1e-3); + } + } + + #[test] + fn a_touch_lights_the_dots_under_it_and_lets_go_after() { + let field = Field::new(Rings::Round).resting(0.4); + let inks = Inks::new(SUNRISE); + let at = |touch: Option| { + field.cells_touched(21, 21, GRID, Mode::Muted, &inks, Outline::Disc, &[], touch) + }; + let quiet = at(None); + let (x, y) = ( + 3.0 * GRID.pitch() + GRID.dot / 2.0, + 10.0 * GRID.pitch() + GRID.dot / 2.0, + ); + let touched = at(Some(Touch { + x, + y, + strength: 1.0, + })); + let dot = |cells: &[Cell], col: f32, row: f32| { + look(cells.iter().find(|c| c.col == col && c.row == row).unwrap()) + }; + // Under the pointer the dot swells and lights; across the field it + // is as it was. + assert!(dot(&touched, 3.0, 10.0).size > dot(&quiet, 3.0, 10.0).size + 0.2); + assert!(dot(&touched, 3.0, 10.0).alpha > dot(&quiet, 3.0, 10.0).alpha); + assert_eq!(dot(&touched, 16.0, 10.0), dot(&quiet, 16.0, 10.0)); + // It holds for a moment, then fades out. + assert_eq!(Touch::strength_after(0.2), 1.0); + assert!(Touch::strength_after(0.8) > 0.0 && Touch::strength_after(0.8) < 1.0); + assert_eq!(Touch::strength_after(1.5), 0.0); + } +} diff --git a/crates/orb/src/lib.rs b/crates/orb/src/lib.rs index 0c91ab0eb3..02962d1fb9 100644 --- a/crates/orb/src/lib.rs +++ b/crates/orb/src/lib.rs @@ -1,23 +1,27 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Thinking orbs — dotted loading indicators for AI & agent UIs, ported from -//! gpui-thinking-orbs (MIT), itself a port of Jakub Antalik's thinking-orbs. -//! -//! Twelve hand-tuned animated states and four size presets, emitted as -//! monochrome geometry ([`engine::Frame`]). Nothing here paints: the desktop -//! widget renders frames through GPUI and the mobile apps through their native -//! 2-D APIs, so every platform shows the same artwork. +//! The voice glyph's engine ([`glyph`]) and the state a call shows, shared by +//! the desktop and the mobile apps so every surface reads a call the same way. +//! Nothing here paints: the desktop draws its dots through GPUI, the phone +//! through Core Graphics, from the same frames. -mod animator; -mod motion; -mod presets; -mod types; +pub mod glyph; -pub mod engine; - -pub use animator::{OrbAnimator, crossfade}; -pub use motion::{AnimationClock, AudioResponse}; -pub use presets::{Resolved, resolve_preset}; -pub use types::{ModeKey, OrbSize, OrbState, OrbTheme}; - -/// Reduced motion freezes every orb on this representative frame. -pub const REDUCED_MOTION_T: f32 = 0.6; +/// What a call is doing, as its glyph shows it. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum OrbState { + /// Codex works on the user's behalf. + #[default] + Working, + /// Codex looks something up. + Searching, + /// Codex waits on the user's answer. + Solving, + /// The user has the floor. + Listening, + /// The call is being placed. + Connecting, + /// Codex speaks. + Composing, + /// The call rests: muted, or not yet heard. + Breathing, +} diff --git a/crates/orb/src/motion.rs b/crates/orb/src/motion.rs deleted file mode 100644 index f044f1d905..0000000000 --- a/crates/orb/src/motion.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Continuous animation time and bounded, independent audio envelopes. -use std::time::Instant; - -#[derive(Default)] -pub struct AnimationClock { - pub seconds: f64, - pub active_seconds: f64, - last: Option, - rate: f64, - running: bool, -} - -impl AnimationClock { - pub fn advance(&mut self, now: Instant, rate: f64, running: bool) { - if let Some(last) = self.last { - if self.running { - let dt = now.saturating_duration_since(last).as_secs_f64(); - self.seconds += dt * self.rate; - self.active_seconds += dt; - } - } - self.last = Some(now); - self.rate = rate; - self.running = running; - } - - pub fn stop(&mut self, now: Instant) { - self.advance(now, self.rate, false); - } - - pub fn is_running(&self) -> bool { - self.running - } -} - -struct Envelope { - origin: f32, - target: f32, - changed: Instant, -} - -impl Envelope { - fn new(now: Instant) -> Self { - Self { - origin: 0.0, - target: 0.0, - changed: now, - } - } - - fn value(&self, now: Instant) -> f32 { - let tau = if self.target > self.origin { - 0.080 - } else { - 0.300 - }; - let dt = now.saturating_duration_since(self.changed).as_secs_f32(); - self.target + (self.origin - self.target) * (-dt / tau).exp() - } - - fn set(&mut self, peak: f32, now: Instant) -> bool { - // Ignore quiet background noise; compress peaks without exceeding 1. - let peak = if peak.is_finite() { - peak.clamp(0.0, 1.0) - } else { - 0.0 - }; - let x = ((peak - 0.015) / 0.985).max(0.0); - let target = 1.2 * x / (x + 0.2); - if self.target == target { - return false; - } - self.origin = self.value(now); - self.target = target; - self.changed = now; - true - } -} - -pub struct AudioResponse { - microphone: Envelope, - speaker: Envelope, -} - -impl AudioResponse { - pub fn new(now: Instant) -> Self { - Self { - microphone: Envelope::new(now), - speaker: Envelope::new(now), - } - } - - pub fn set(&mut self, microphone: f32, speaker: f32, now: Instant) -> bool { - let mic_changed = self.microphone.set(microphone, now); - let speaker_changed = self.speaker.set(speaker, now); - mic_changed || speaker_changed - } - - pub fn speed(&self, now: Instant) -> f64 { - let mic = self.microphone.value(now); - let speaker = self.speaker.value(now); - // A bounded, continuous combination, even when both sides overlap. - 1.0 + f64::from((1.0 - (1.0 - mic) * (1.0 - speaker)).clamp(0.0, 1.0)) * 0.3 - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - - #[test] - fn changing_speed_after_long_uptime_preserves_position() { - let now = Instant::now(); - let mut clock = AnimationClock::default(); - clock.advance(now, 1.0, true); - let later = now + Duration::from_secs(3600); - clock.advance(later, 2.0, true); - assert_eq!(clock.seconds, 3600.0); - clock.advance(later + Duration::from_millis(100), 2.0, true); - assert!((clock.seconds - 3600.2).abs() < 1e-8); - } - - #[test] - fn hidden_and_paused_time_does_not_advance_animation() { - let now = Instant::now(); - let mut clock = AnimationClock::default(); - clock.advance(now, 2.0, true); - clock.stop(now + Duration::from_secs(1)); - clock.advance(now + Duration::from_secs(60), 3.0, true); - assert_eq!(clock.seconds, 2.0); - clock.advance(now + Duration::from_secs(61), 3.0, true); - assert_eq!(clock.seconds, 5.0); - assert_eq!(clock.active_seconds, 2.0); - } - - #[test] - fn audio_ignores_noise_and_smooths_attack_release_and_channel_overlap() { - let now = Instant::now(); - let mut audio = AudioResponse::new(now); - audio.set(0.01, f32::NAN, now); - assert_eq!(audio.speed(now + Duration::from_secs(1)), 1.0); - audio.set(1.0, 0.0, now); - assert_eq!(audio.speed(now), 1.0); - let attack = now + Duration::from_millis(80); - assert!((1.15..1.25).contains(&audio.speed(attack))); - let before = audio.speed(attack); - audio.set(0.0, 1.0, attack); - assert_eq!(audio.speed(attack), before); - for ms in 80..1000 { - assert!((1.0..=1.300001).contains(&audio.speed(now + Duration::from_millis(ms)))); - } - let quiet = now + Duration::from_secs(1); - audio.set(0.0, 0.0, quiet); - assert!(audio.speed(quiet + Duration::from_millis(300)) > 1.05); - assert!(audio.speed(quiet + Duration::from_secs(3)) < 1.001); - } -} diff --git a/crates/orb/src/presets.rs b/crates/orb/src/presets.rs deleted file mode 100644 index 7fa5f6f9db..0000000000 --- a/crates/orb/src/presets.rs +++ /dev/null @@ -1,354 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Twelve states × four sizes — original ports plus library-native concepts. - -use crate::{ - engine::{ModeOpts, base_profile, scale_counts, scale_radii}, - types::{ModeKey, OrbSize, OrbState}, -}; - -/// Fully resolved draw options for a (state, size) pair. -#[derive(Clone, Debug)] -pub struct Resolved { - pub mode: ModeKey, - pub speed: f32, - pub opts: ModeOpts, -} - -struct Preset { - speed: f32, - count: f32, - size: f32, - // extra knobs merged after scaling - scan_mul: Option, - dim_base: Option, - spin: Option, - band_mul: Option, - wob_mul: Option, - spread: Option, -} - -fn preset_for(mode: ModeKey, size: OrbSize) -> Preset { - use ModeKey::*; - - // The large designs preserve each avatar preset's character while adding - // detail gradually. `scale_counts` understands each painter's topology - // (2-D lattices, flat lists, lane/segment pairs), so this is deliberately - // not a naive coordinate scale. - if matches!(size, OrbSize::Large | OrbSize::Hero) { - let mut preset = preset_for(mode, OrbSize::Avatar); - let (count, radius, speed) = match size { - OrbSize::Large => (1.15, 1.05, 0.95), - OrbSize::Hero => (1.30, 1.10, 0.90), - _ => unreachable!(), - }; - preset.count *= count; - preset.size *= radius; - preset.speed *= speed; - return preset; - } - - match (mode, size) { - (Orbits, OrbSize::Avatar) => Preset { - speed: 1.885, - count: 1.0, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Orbits, OrbSize::Inline) => Preset { - speed: 3.9, - count: 0.238, - size: 2.4, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Globe, OrbSize::Avatar) => Preset { - speed: 2.015, - count: 0.42, - size: 1.15, - scan_mul: Some(4.08), - dim_base: Some(0.45), - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Globe, OrbSize::Inline) => Preset { - speed: 2.665, - count: 0.105, - size: 1.75, - scan_mul: Some(4.335), - dim_base: Some(0.45), - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Rubik, OrbSize::Avatar) => Preset { - speed: 1.82, - count: 0.35, - size: 1.05, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Rubik, OrbSize::Inline) => Preset { - speed: 1.95, - count: 0.088, - size: 1.9, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Wave, OrbSize::Avatar) => Preset { - speed: 3.15, - count: 0.341, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Wave, OrbSize::Inline) => Preset { - speed: 3.35, - count: 0.105, - size: 1.6, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Web, OrbSize::Avatar) => Preset { - speed: 3.315, - count: 1.35, - size: 0.95, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Web, OrbSize::Inline) => Preset { - speed: 6.63, - count: 0.25, - size: 1.52, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Braid, OrbSize::Avatar) => Preset { - speed: 1.625, - count: 0.5, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Braid, OrbSize::Inline) => Preset { - speed: 2.75, - count: 0.1125, - size: 1.36, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: None, - }, - (Ribbon, OrbSize::Avatar) => Preset { - speed: 1.95, - count: 0.25, - size: 0.85, - scan_mul: None, - dim_base: None, - spin: Some(0.0), - band_mul: Some(2.9), - wob_mul: Some(1.0), - spread: None, - }, - (Ribbon, OrbSize::Inline) => Preset { - speed: 2.5, - count: 0.051, - size: 1.073, - scan_mul: None, - dim_base: None, - spin: Some(0.0), - band_mul: Some(3.6), - wob_mul: Some(1.0), - spread: None, - }, - (Ring, OrbSize::Avatar) => Preset { - speed: 1.55, - count: 0.25, - size: 0.956, - scan_mul: None, - dim_base: None, - spin: Some(0.0), - band_mul: Some(2.8), - wob_mul: Some(0.368), - spread: None, - }, - (Ring, OrbSize::Inline) => Preset { - speed: 2.1, - count: 0.028, - size: 1.622, - scan_mul: None, - dim_base: None, - spin: Some(0.0), - band_mul: Some(3.2), - wob_mul: Some(0.565), - spread: None, - }, - (Morph, OrbSize::Avatar) => Preset { - speed: 2.405, - count: 0.702, - size: 0.395, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.45), - }, - (Morph, OrbSize::Inline) => Preset { - speed: 2.08, - count: 0.53, - size: 1.011, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.45), - }, - (Focus, OrbSize::Avatar) => Preset { - speed: 1.72, - count: 1.0, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (Focus, OrbSize::Inline) => Preset { - speed: 2.2, - count: 0.22, - size: 1.45, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (Gyroscope, OrbSize::Avatar) => Preset { - speed: 1.48, - count: 1.0, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (Gyroscope, OrbSize::Inline) => Preset { - speed: 2.05, - count: 0.25, - size: 1.55, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (Echo, OrbSize::Avatar) => Preset { - speed: 1.85, - count: 1.0, - size: 1.0, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (Echo, OrbSize::Inline) => Preset { - speed: 2.45, - count: 0.25, - size: 1.55, - scan_mul: None, - dim_base: None, - spin: None, - band_mul: None, - wob_mul: None, - spread: Some(1.0), - }, - (_, OrbSize::Large | OrbSize::Hero) => unreachable!(), - } -} - -/// Resolve a (state, size) pair to its mode + fully-scaled draw options. -pub fn resolve_preset(state: OrbState, size: OrbSize) -> Resolved { - let mode = ModeKey::from_state(state); - let preset = preset_for(mode, size); - let mut opts = base_profile(mode); - if (preset.count - 1.0).abs() > f32::EPSILON { - opts = scale_counts(&opts, preset.count); - } - if (preset.size - 1.0).abs() > f32::EPSILON { - opts = scale_radii(&opts, preset.size); - } - if let Some(v) = preset.scan_mul { - opts.scan_mul = Some(v); - } - if let Some(v) = preset.dim_base { - opts.dim_base = Some(v); - } - if let Some(v) = preset.spin { - opts.spin = Some(v); - } - if let Some(v) = preset.band_mul { - opts.band_mul = Some(v); - } - if let Some(v) = preset.wob_mul { - opts.wob_mul = Some(v); - } - if let Some(v) = preset.spread { - opts.spread = Some(v); - } - - Resolved { - mode, - speed: preset.speed, - opts, - } -} diff --git a/crates/orb/src/types.rs b/crates/orb/src/types.rs deleted file mode 100644 index 3a88b54cc8..0000000000 --- a/crates/orb/src/types.rs +++ /dev/null @@ -1,217 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Public types shared by the animation engine and the GPUI widget. - -/// The twelve shipped states — each a hand-tuned animation. -/// -/// Marked `#[non_exhaustive]`: matching on this from another crate needs a -/// wildcard arm, so shipping a tenth state is not a breaking change. Prefer -/// [`OrbState::label`] / [`OrbState::as_str`] over matching where you can. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum OrbState { - /// Particles on tilted orbits. - #[default] - Working, - /// A scan meridian sweeps a dotted globe. - Searching, - /// Bands scramble in quarter turns, then click back. - Solving, - /// A waveform rolls through latitude rings. - Listening, - /// A constellation wires itself, packets running the edges. - Connecting, - /// Three strands plait around the sphere. - Weaving, - /// An undulating multi-band sash. - Composing, - /// A face-on ring slowly morphing. - Breathing, - /// A dotted outline morphs circle → triangle → square. - Shaping, - /// An iris of particles converges and relaxes around a focal point. - Focusing, - /// Counter-rotating great circles form a reasoning gyroscope. - Reasoning, - /// Concentric memory echoes travel out from a steady core. - Recalling, -} - -impl OrbState { - /// All states in playground / gallery order. - /// - /// This is a slice so adding future states does not change its public type. - pub const ALL_STATES: &'static [OrbState] = &[ - OrbState::Working, - OrbState::Searching, - OrbState::Solving, - OrbState::Listening, - OrbState::Connecting, - OrbState::Weaving, - OrbState::Composing, - OrbState::Breathing, - OrbState::Shaping, - OrbState::Focusing, - OrbState::Reasoning, - OrbState::Recalling, - ]; - - /// The original nine-state gallery. - /// - /// Kept at its original array type for source compatibility. New code - /// should iterate [`Self::ALL_STATES`]. - #[deprecated(since = "0.2.0", note = "use OrbState::ALL_STATES")] - pub const ALL: [OrbState; 9] = [ - OrbState::Working, - OrbState::Searching, - OrbState::Solving, - OrbState::Listening, - OrbState::Connecting, - OrbState::Weaving, - OrbState::Composing, - OrbState::Breathing, - OrbState::Shaping, - ]; - - /// Human-readable status label (matches upstream aria defaults). - pub fn label(self) -> &'static str { - match self { - OrbState::Working => "Working…", - OrbState::Searching => "Searching…", - OrbState::Solving => "Solving…", - OrbState::Listening => "Listening…", - OrbState::Connecting => "Connecting…", - OrbState::Weaving => "Weaving…", - OrbState::Composing => "Composing…", - OrbState::Breathing => "Thinking…", - OrbState::Shaping => "Shaping…", - OrbState::Focusing => "Focusing…", - OrbState::Reasoning => "Reasoning…", - OrbState::Recalling => "Recalling…", - } - } - - /// Stable snake_case name (matches the web package `state` prop). - pub fn as_str(self) -> &'static str { - match self { - OrbState::Working => "working", - OrbState::Searching => "searching", - OrbState::Solving => "solving", - OrbState::Listening => "listening", - OrbState::Connecting => "connecting", - OrbState::Weaving => "weaving", - OrbState::Composing => "composing", - OrbState::Breathing => "breathing", - OrbState::Shaping => "shaping", - OrbState::Focusing => "focusing", - OrbState::Reasoning => "reasoning", - OrbState::Recalling => "recalling", - } - } -} - -/// Rendered size in logical pixels. Four tuned presets ship: inline (20), -/// avatar (64), large (96), and hero (128). Larger sizes add detail gradually -/// instead of merely stretching the 64 px artwork. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum OrbSize { - /// Chat-avatar scale (64 logical px). - #[default] - Avatar, - /// Inline-text scale (20 logical px). - Inline, - /// Prominent status / card scale (96 logical px). - Large, - /// Hero / empty-state scale (128 logical px). - Hero, -} - -impl OrbSize { - /// All sizes in compact-to-prominent gallery order. - pub const ALL_SIZES: &'static [OrbSize] = &[ - OrbSize::Inline, - OrbSize::Avatar, - OrbSize::Large, - OrbSize::Hero, - ]; - - /// Logical pixel edge length of the orb. - pub fn pixels(self) -> f32 { - match self { - OrbSize::Inline => 20.0, - OrbSize::Avatar => 64.0, - OrbSize::Large => 96.0, - OrbSize::Hero => 128.0, - } - } - - /// Stable lowercase name for controls and command-line arguments. - pub fn as_str(self) -> &'static str { - match self { - OrbSize::Inline => "inline", - OrbSize::Avatar => "avatar", - OrbSize::Large => "large", - OrbSize::Hero => "hero", - } - } - - /// Compact playground label. - pub fn label(self) -> &'static str { - match self { - OrbSize::Inline => "20 · inline", - OrbSize::Avatar => "64 · avatar", - OrbSize::Large => "96 · large", - OrbSize::Hero => "128 · hero", - } - } -} - -/// Theme mode for monochrome ink on transparent canvas. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] -pub enum OrbTheme { - /// Resolve from the window appearance (GPUI `WindowAppearance`). - #[default] - Auto, - /// Light ink for dark backgrounds. - Dark, - /// Dark ink for light backgrounds. - Light, -} - -/// Internal mode keys — one painter per key. Grows in lockstep with -/// [`OrbState`], hence `#[non_exhaustive]`. -#[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum ModeKey { - Orbits, - Globe, - Rubik, - Wave, - Web, - Braid, - Ribbon, - Ring, - Morph, - Focus, - Gyroscope, - Echo, -} - -impl ModeKey { - pub fn from_state(state: OrbState) -> Self { - match state { - OrbState::Working => ModeKey::Orbits, - OrbState::Searching => ModeKey::Globe, - OrbState::Solving => ModeKey::Rubik, - OrbState::Listening => ModeKey::Wave, - OrbState::Connecting => ModeKey::Web, - OrbState::Weaving => ModeKey::Braid, - OrbState::Composing => ModeKey::Ribbon, - OrbState::Breathing => ModeKey::Ring, - OrbState::Shaping => ModeKey::Morph, - OrbState::Focusing => ModeKey::Focus, - OrbState::Reasoning => ModeKey::Gyroscope, - OrbState::Recalling => ModeKey::Echo, - } - } -} diff --git a/crates/ui/tests/voice_orb_geometry.rs b/crates/ui/tests/voice_orb_geometry.rs deleted file mode 100644 index 843eb0c7ee..0000000000 --- a/crates/ui/tests/voice_orb_geometry.rs +++ /dev/null @@ -1,352 +0,0 @@ -// MIT: extracted with the orb; see THIRD_PARTY_NOTICES.md. -//! Smoke tests + microbench for mode painters. - -use zeron_ui::orb::{ - ModeKey, OrbSize, OrbState, - engine::{MAX_ICON_D, MAX_MORPH_DOTS, MAX_NODE_N, ModeOpts, draw_mode, sanitize_mode_opts}, - resolve_preset, -}; - -#[test] -fn all_states_emit_geometry() { - for &state in OrbState::ALL_STATES { - for &size in OrbSize::ALL_SIZES { - let resolved = resolve_preset(state, size); - let frame = draw_mode(resolved.mode, size.pixels(), 1.25, &resolved.opts); - assert!( - !frame.dots.is_empty(), - "{:?}/{:?} produced zero dots", - state, - size - ); - for d in &frame.dots { - assert!(d.r.is_finite() && d.r >= 0.0); - assert!(d.x.is_finite() && d.y.is_finite() && d.z.is_finite()); - assert!(d.a >= 0.0 && d.a <= 1.0 + 1e-3); - } - } - } -} - -#[test] -fn connecting_emits_lines() { - let resolved = resolve_preset(OrbState::Connecting, OrbSize::Avatar); - let frame = draw_mode(resolved.mode, 64.0, 2.0, &resolved.opts); - assert!(!frame.lines.is_empty(), "connecting should wire edges"); - assert!(!frame.dots.is_empty()); -} - -#[test] -fn reduced_motion_frame_is_deterministic() { - let resolved = resolve_preset(OrbState::Working, OrbSize::Avatar); - let a = draw_mode(resolved.mode, 64.0, 0.6, &resolved.opts); - let b = draw_mode(resolved.mode, 64.0, 0.6, &resolved.opts); - assert_eq!(a.dots.len(), b.dots.len()); - for (da, db) in a.dots.iter().zip(b.dots.iter()) { - assert!((da.x - db.x).abs() < 1e-4); - assert!((da.y - db.y).abs() < 1e-4); - } -} - -#[test] -fn solve_cycle_survives_float_boundary_move_counts() { - // `move_count` is a public `ModeOpts` knob. Certain values put the slot - // boundary exactly on a float rounding edge, where `tc / slot_dur` reaches - // `2 * count` even though the `tc < 2 * count * slot_dur` guard passed — - // which used to underflow `2*count-1-slot` to `usize::MAX` and panic. - // - // Sweep enough move counts and adjacent float values to exercise rounding - // at both forward and reverse slot boundaries. - const SLOT_DUR: f32 = 0.58; - let resolved = resolve_preset(OrbState::Solving, OrbSize::Avatar); - for count in 1..40u32 { - let mut opts = resolved.opts.clone(); - opts.move_count = Some(count as f32); - for c in 1..=count { - // Step onto the largest float strictly below each slot boundary. - let boundary = 2.0 * c as f32 * SLOT_DUR; - for t in [ - f32::from_bits(boundary.to_bits() - 1), - boundary, - f32::from_bits(boundary.to_bits() + 1), - ] { - let frame = draw_mode(resolved.mode, 64.0, t, &opts); - assert!(!frame.dots.is_empty()); - } - } - } -} - -#[test] -fn new_concepts_are_distinct_low_density_modes() { - let concepts = [ - (OrbState::Focusing, ModeKey::Focus, 77, 23), - (OrbState::Reasoning, ModeKey::Gyroscope, 90, 32), - (OrbState::Recalling, ModeKey::Echo, 75, 21), - ]; - - for (state, mode, avatar_dots, inline_dots) in concepts { - assert_eq!(ModeKey::from_state(state), mode); - assert!(OrbState::ALL_STATES.contains(&state)); - for (size, expected) in [ - (OrbSize::Avatar, avatar_dots), - (OrbSize::Inline, inline_dots), - ] { - let resolved = resolve_preset(state, size); - let frame = draw_mode(resolved.mode, size.pixels(), 0.6, &resolved.opts); - assert_eq!(frame.dots.len(), expected, "{state:?}/{size:?}"); - assert!( - frame.lines.is_empty(), - "new concepts stay on the dot-only paint path" - ); - assert!( - frame.dots.len() < 100, - "new concepts must remain low density" - ); - } - } -} - -#[test] -fn new_concepts_animate_and_keep_their_depth_identity() { - for &state in &[OrbState::Focusing, OrbState::Reasoning, OrbState::Recalling] { - let resolved = resolve_preset(state, OrbSize::Avatar); - let a = draw_mode(resolved.mode, 64.0, 0.2, &resolved.opts); - let b = draw_mode(resolved.mode, 64.0, 1.2, &resolved.opts); - assert!( - a.dots - .iter() - .zip(&b.dots) - .any(|(a, b)| (a.x - b.x).abs() > 0.01 || (a.y - b.y).abs() > 0.01), - "{state:?} did not animate" - ); - - let has_depth = a.dots.iter().any(|dot| dot.z.abs() > 0.01); - assert_eq!(has_depth, state == OrbState::Reasoning); - } -} - -#[test] -fn larger_sizes_add_detail_without_changing_the_legacy_presets() { - assert_eq!(OrbSize::ALL_SIZES.len(), 4); - assert_eq!(OrbSize::Large.pixels(), 96.0); - assert_eq!(OrbSize::Hero.pixels(), 128.0); - - for &state in OrbState::ALL_STATES { - let avatar = resolve_preset(state, OrbSize::Avatar); - let large = resolve_preset(state, OrbSize::Large); - let hero = resolve_preset(state, OrbSize::Hero); - let avatar_frame = draw_mode(avatar.mode, 64.0, 0.6, &avatar.opts); - let large_frame = draw_mode(large.mode, 96.0, 0.6, &large.opts); - let hero_frame = draw_mode(hero.mode, 128.0, 0.6, &hero.opts); - - assert_eq!(avatar.mode, large.mode); - assert_eq!(avatar.mode, hero.mode); - assert!( - large_frame.dots.len() >= avatar_frame.dots.len(), - "{state:?}/large" - ); - assert!( - hero_frame.dots.len() >= large_frame.dots.len(), - "{state:?}/hero" - ); - assert!(large.speed <= avatar.speed); - assert!(hero.speed <= large.speed); - } -} - -#[test] -fn legacy_all_constant_keeps_its_original_array_shape() { - #[allow(deprecated)] - let original_nine: [OrbState; 9] = OrbState::ALL; - assert_eq!(original_nine.len(), 9); - assert_eq!(OrbState::ALL_STATES.len(), 12); -} - -#[test] -fn bench_geometry_cost() { - use std::time::Instant; - for &state in OrbState::ALL_STATES { - for &size in OrbSize::ALL_SIZES { - let resolved = resolve_preset(state, size); - for i in 0..20 { - let _ = draw_mode( - resolved.mode, - size.pixels(), - i as f32 * 0.016, - &resolved.opts, - ); - } - let n = 1000usize; - let t0 = Instant::now(); - let mut total_dots = 0usize; - let mut total_lines = 0usize; - for i in 0..n { - let f = draw_mode( - resolved.mode, - size.pixels(), - i as f32 * 0.016, - &resolved.opts, - ); - total_dots += f.dots.len(); - total_lines += f.lines.len(); - } - let elapsed = t0.elapsed(); - let per = elapsed / n as u32; - let avg_dots = total_dots / n; - let avg_lines = total_lines / n; - eprintln!( - "{:>12}/{:<6} {:>5} dots {:>4} lines {:>8.2?} /frame (~{:.0}k geom-fps)", - state.as_str(), - size.pixels() as u32, - avg_dots, - avg_lines, - per, - 1.0 / per.as_secs_f64() / 1000.0 - ); - } - } -} - -/// Strokes are painted at their exact colour, one `Path` per segment. Batching -/// them into shared paths was tried and reverted for visibly brightening the -/// web (see `paint.rs`), so what matters now is that every alpha the engine -/// emits is in range and finite — a stroke painter has no clamping to hide -/// behind. -#[test] -fn stroke_colours_are_paintable() { - let resolved = resolve_preset(OrbState::Connecting, OrbSize::Avatar); - let mut seen = 0usize; - for i in 0..400 { - let frame = draw_mode(resolved.mode, 64.0, i as f32 * 0.05, &resolved.opts); - for l in &frame.lines { - assert!( - l.a.is_finite() && (0.0..=1.0).contains(&l.a), - "alpha {}", - l.a - ); - assert!( - l.white.is_finite() && (0.0..=1.0).contains(&l.white), - "white {}", - l.white - ); - assert!(l.w.is_finite() && l.w > 0.0, "stroke width {}", l.w); - assert!(l.x1.is_finite() && l.y1.is_finite()); - assert!(l.x2.is_finite() && l.y2.is_finite()); - seen += 1; - } - } - assert!(seen > 0, "connecting emitted no strokes across 400 frames"); -} - -#[test] -fn adversarial_mode_opts_stay_bounded_and_finite() { - // Power-user knobs used to accept Inf / huge finites and OOM or NaN. - // draw_mode_into sanitizes first; geometry must stay finite and small. - let cases: &[(ModeKey, ModeOpts)] = &[ - ( - ModeKey::Web, - ModeOpts::fill(ModeKey::Web, |o| { - o.node_n = Some(f32::INFINITY); - o.signals = Some(1.0e12); - }), - ), - ( - ModeKey::Globe, - ModeOpts::fill(ModeKey::Globe, |o| { - o.lat_rings = Some(0.0); - o.lon_density = Some(5_000.0); - }), - ), - ( - ModeKey::Rubik, - ModeOpts::fill(ModeKey::Rubik, |o| { - o.lat_rings = Some(0.0); - o.move_count = Some(1.0e7); - }), - ), - ( - ModeKey::Wave, - ModeOpts::fill(ModeKey::Wave, |o| { - o.rings = Some(0.0); - o.lon_density = Some(f32::NAN); - }), - ), - ( - ModeKey::Orbits, - ModeOpts::fill(ModeKey::Orbits, |o| { - o.orbit_n = Some(f32::INFINITY); - o.ghost_n = Some(1.0e9); - o.particles = Some(1.0e6); - }), - ), - ( - ModeKey::Morph, - ModeOpts::fill(ModeKey::Morph, |o| { - o.icon_d = Some(1.0e6); - }), - ), - ( - ModeKey::Ribbon, - ModeOpts::fill(ModeKey::Ribbon, |o| { - o.lanes = Some(1.0e6); - o.segs = Some(1.0e6); - o.band_mul = Some(1.0e6); - o.ghost_n = Some(1.0e9); - }), - ), - ( - ModeKey::Braid, - ModeOpts::fill(ModeKey::Braid, |o| { - o.strand_n = Some(1.0e9); - o.ghost_n = Some(1.0e9); - }), - ), - ]; - - for (mode, opts) in cases { - let frame = draw_mode(*mode, 64.0, 1.25, opts); - assert!( - frame.dots.len() <= 80_000, - "{mode:?} emitted {} dots — sanitize failed", - frame.dots.len() - ); - for d in &frame.dots { - assert!(d.x.is_finite() && d.y.is_finite() && d.z.is_finite() && d.r.is_finite()); - } - for l in &frame.lines { - assert!(l.x1.is_finite() && l.y1.is_finite() && l.x2.is_finite() && l.y2.is_finite()); - } - } - - let morph = draw_mode( - ModeKey::Morph, - 64.0, - 0.6, - &ModeOpts::fill(ModeKey::Morph, |o| { - o.icon_d = Some(MAX_ICON_D * 100.0); - }), - ); - assert!(morph.dots.len() <= MAX_MORPH_DOTS); - - let web = draw_mode( - ModeKey::Web, - 64.0, - 1.0, - &ModeOpts::fill(ModeKey::Web, |o| { - o.node_n = Some(MAX_NODE_N * 10.0); - }), - ); - // node_n capped → dots include nodes + a few signal packets - assert!(web.dots.len() < MAX_NODE_N as usize + 100); - - // The base mode is irrelevant here: the sanitizer is what is on trial. - let clean = sanitize_mode_opts(&ModeOpts::fill(ModeKey::Web, |o| { - o.node_n = Some(f32::INFINITY); - o.lat_rings = Some(-3.0); - o.icon_d = Some(f32::NAN); - })); - assert_eq!(clean.node_n, None); // non-finite → dropped - assert_eq!(clean.lat_rings, Some(1.0)); // clamped up - assert_eq!(clean.icon_d, None); -} From a6cd7b15fdc865699a58f2e99da4c3865de629e1 Mon Sep 17 00:00:00 2001 From: gaelcado Date: Thu, 15 Oct 2026 17:32:15 +0200 Subject: [PATCH 2/8] Desktop: the voice glyph on the stage, the call strip, the track and the footer The stage orb is the glyph's field in the composer's own disc, graded from a hot heart to small dots at the rim; the pointer over it lights the dots under it. Every conversation's composer carries the call in a tray on the queue's surface, its beam the dictation track's twin. The sidebar footer shows the beam in miniature. The user's voice is heard through the microphone's own spectrum, gated while Codex plays so it does not come back as the user's. The stage transcript keeps every turn, centred while it fits and scrolling once it does not. --- crates/ui/assets/icons/phone-hang-up.svg | 2 +- crates/ui/assets/icons/voice-wave.svg | 1 + crates/ui/src/composer.rs | 491 +++++++++------- crates/ui/src/composer_dictation_tests.rs | 129 ++--- crates/ui/src/dictation.rs | 65 ++- crates/ui/src/dictation/glass.rs | 28 +- crates/ui/src/dictation/meter.rs | 2 - crates/ui/src/dictation/model.rs | 7 +- crates/ui/src/dictation/waveform.rs | 140 +---- crates/ui/src/icons.rs | 1 + crates/ui/src/lib.rs | 1 - crates/ui/src/orb/mod.rs | 19 - crates/ui/src/orb/orb.rs | 451 --------------- crates/ui/src/orb/paint.rs | 133 ----- crates/ui/src/shell.rs | 545 ++++++++++-------- crates/ui/src/shell/voice_stage.rs | 671 +++++++++++++++------- crates/ui/src/voice.rs | 315 +++++++++- crates/ui/src/voice/glyph.rs | 135 +++++ crates/ui/src/voice/hearing.rs | 93 +++ crates/voice/src/lib.rs | 178 +++++- crates/voice/src/session_tests.rs | 79 ++- crates/voice/src/spectrum.rs | 391 +++++++++++++ 22 files changed, 2375 insertions(+), 1502 deletions(-) create mode 100644 crates/ui/assets/icons/voice-wave.svg delete mode 100644 crates/ui/src/orb/mod.rs delete mode 100644 crates/ui/src/orb/orb.rs delete mode 100644 crates/ui/src/orb/paint.rs create mode 100644 crates/ui/src/voice/glyph.rs create mode 100644 crates/ui/src/voice/hearing.rs create mode 100644 crates/voice/src/spectrum.rs diff --git a/crates/ui/assets/icons/phone-hang-up.svg b/crates/ui/assets/icons/phone-hang-up.svg index de2cf1aafe..ee08bad233 100644 --- a/crates/ui/assets/icons/phone-hang-up.svg +++ b/crates/ui/assets/icons/phone-hang-up.svg @@ -1 +1 @@ - + diff --git a/crates/ui/assets/icons/voice-wave.svg b/crates/ui/assets/icons/voice-wave.svg new file mode 100644 index 0000000000..6d3d02a988 --- /dev/null +++ b/crates/ui/assets/icons/voice-wave.svg @@ -0,0 +1 @@ + diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index 2a1af941f7..e197b94d80 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -80,7 +80,7 @@ pub const COMPACT_TOTAL_HEIGHT: f32 = 49.0; /// width is supplied. Established threads follow the conversation column. pub const COMPOSER_MAX_WIDTH: f32 = 768.0; /// The queue reads as a narrower tray emerging from behind the composer. -const QUEUE_SIDE_INSET: f32 = 16.0; +pub(crate) const QUEUE_SIDE_INSET: f32 = 16.0; /// The composer covers the tray's lower padding so the queue reads as emerging /// from behind it instead of as a separate rounded pill. pub(crate) const QUEUE_COMPOSER_OVERLAP: f32 = 18.0; @@ -1848,19 +1848,81 @@ const VOICE_MORPH: Duration = Duration::from_millis(420); const VOICE_CURVE: motion::CubicBezier = motion::CubicBezier::new(0.2, 0.0, 0.0, 1.0); const VOICE_LIMIT_WARNING: Duration = Duration::from_secs(10); /// Height of the voice track and its gap to the Stop control. -const VOICE_TRACK_HEIGHT: f32 = 32.0; +const VOICE_TRACK_HEIGHT: f32 = 28.0; const VOICE_TRACK_GAP: f32 = 8.0; +/// The room the track's beam leaves for the clock. +const VOICE_CLOCK_ROOM: f32 = 38.0; +/// The beam dissolves over this many points before the clock: a long, +/// soft run-out that reaches close to it. +const VOICE_BEAM_FADE: f32 = 56.0; /// Everything the voice track draws, captured from the dictation meter. #[derive(Clone)] struct VoiceFrame { label: String, mode: crate::dictation::waveform::Mode, - bars: Vec, - level: f32, elapsed: Option, } +/// A scripted dictation (`ZERON_DICTATION_PREVIEW`) has a made-up voice: the +/// microphone is not read. +fn scripted_dictation() -> bool { + cfg!(debug_assertions) && std::env::var_os("ZERON_DICTATION_PREVIEW").is_some() +} + +/// The dictation's voice, band by band, for the track's beam: the +/// microphone's own spectrum where it can be read beside the dictation's +/// capture, else the rhythm of its loudness. +struct VoiceBands { + ears: Option, + rhythm: zeron_orb::glyph::Rhythm, + beam: crate::voice::glyph::Beam, + /// The working glyph shown while the words are read, in the stop's spot. + spinner: crate::voice::glyph::Field, +} + +impl Default for VoiceBands { + fn default() -> Self { + let mut spinner = crate::voice::glyph::Field::new(crate::voice::glyph::Rings::Round); + spinner.shift(crate::voice::glyph::Mode::Thinking); + Self { + ears: None, + rhythm: Default::default(), + beam: Default::default(), + spinner, + } + } +} + +impl VoiceBands { + /// Hears the dictation at `now`: `level` is the meter's, 0–1, and + /// `live` whether the microphone is capturing. + fn hear(&mut self, live: bool, level: f32, now: Instant) { + if !live { + self.ears = None; + } else if self.ears.is_none() && !cfg!(test) && !scripted_dictation() { + self.ears = zeron_voice::spectrum::Listener::start().ok(); + } + // The meter's level, back to the peak the rhythm reads. + self.rhythm + .hear(10f32.powf((level * 38.0 - 50.0) / 20.0), now); + let user = match (&self.ears, live) { + (_, false) => [0.0; crate::voice::glyph::BANDS], + (Some(ears), true) => ears.levels().unwrap_or(self.rhythm.bands()), + (None, true) => self.rhythm.bands(), + }; + self.beam.hear( + crate::voice::glyph::Voice { + user, + agent: [0.0; crate::voice::glyph::BANDS], + }, + now, + ); + self.spinner + .hear(crate::voice::glyph::Voice::default(), now); + } +} + /// Interruptible 0↔1 progress of the dictation morph: a retarget starts from /// the current value, so toggling mid-flight reverses without a jump. #[derive(Clone, Copy)] @@ -6147,6 +6209,7 @@ pub struct Composer { /// after the transcript lands instead of vanishing. voice_last: Option, voice_tween: VoiceTween, + voice_bands: VoiceBands, } impl EventEmitter for Composer {} @@ -6293,6 +6356,38 @@ impl Composer { this.on_input_edited(cx); }) .detach(); + // A scripted dictation (`ZERON_DICTATION_PREVIEW`) starts on its own + // once the composer is up, so its surfaces can be looked at. + #[cfg(debug_assertions)] + if let Ok(preview) = std::env::var("ZERON_DICTATION_PREVIEW") { + // `ZERON_DICTATION_PREVIEW=` lets go after that long, so + // the transcription and the track folding away can be seen. + let release: Option = preview.parse().ok().filter(|secs| *secs > 1.0); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(600)) + .await; + this.update(cx, |this, cx| this.toggle_dictation(false, cx)) + .ok(); + if let Some(secs) = release { + cx.background_executor() + .timer(Duration::from_secs_f32(secs)) + .await; + this.update(cx, |this, cx| this.toggle_dictation(false, cx)) + .ok(); + } + }) + .detach(); + } + // A voice call that takes the microphone ends dictation, keeping what + // was said; muting it hands the microphone back. + cx.observe_global::(|this, cx| { + if crate::voice::listening(cx) && this.input.read(cx).dictation.phase.active() { + this.stop_dictation(cx); + } + cx.notify(); + }) + .detach(); let dictation_events = cx.subscribe(&input, |this: &mut Self, _, event, cx| match event { DictationInputEvent::Press => this.press_dictation(HoldSource::Key, cx), DictationInputEvent::Release => this.release_dictation(HoldSource::Key, cx), @@ -6400,6 +6495,7 @@ impl Composer { dictation_focus: cx.focus_handle(), voice_last: None, voice_tween: VoiceTween::default(), + voice_bands: VoiceBands::default(), }; // Dev knob: pre-stage attachments (drop/paste can't be synthesized on // a rig) — `ZERON_ATTACH=/path/a.png[,/path/b.png]`, and @@ -10072,6 +10168,8 @@ impl Composer { ); cx.emit(DictationInputEvent::Changed); } + // A voice call has the microphone: one voice at a time. + _ if crate::voice::listening(cx) => {} _ => { if let Some(service) = crate::dictation::start(cx) { input.begin_dictation(service, cx); @@ -10101,7 +10199,9 @@ impl Composer { } self.dictation_hold = None; match phase { - Phase::Finalizing => {} + // The working glyph is the one control left: pressing it drops + // the transcription and keeps the draft. + Phase::Finalizing => self.dismiss_dictation(cx), Phase::Requesting | Phase::Listening => { self.dictation_hold = Some(DictationHold { source, @@ -10161,29 +10261,25 @@ impl Composer { cx.notify(); } - /// Microphone at rest; the one Stop control while dictating. `t` morphs - /// the mic glyph out and the accent glass plate, stop square or spinner - /// in; the glow follows the live voice level. + /// Microphone at rest. Dictating, the track runs over it and the cross + /// at the track's head is the one control; `t` morphs the mic glyph out. fn render_dictation_button( &self, t: f32, - frame: Option<&VoiceFrame>, + _frame: Option<&VoiceFrame>, cx: &mut Context, ) -> Option { - use crate::dictation::{Phase, glass, waveform::Mode}; - if !crate::dictation::enabled(cx) || self.wizard.is_some() { + // While a voice call has the microphone, dictation is not offered. + if !crate::dictation::enabled(cx) + || self.wizard.is_some() + || (crate::voice::listening(cx) && !self.input.read(cx).dictation.phase.active()) + { return None; } let theme = Theme::of(cx).clone(); let phase = self.input.read(cx).dictation.phase.clone(); let active = phase.active(); let label = phase.action_label().to_owned(); - let live = frame.is_some_and(|f| f.mode == Mode::Live); - let glow = frame.map_or(0.0, |f| f.level * f.level.sqrt()); - // Spinner while the microphone opens or the model transcribes; the - // stop square once the voice is live (and while retracting from it). - let busy = matches!(phase, Phase::Requesting | Phase::Finalizing) - || (!active && frame.is_some_and(|f| f.mode != Mode::Live)); let tooltip = label.clone(); let composer = cx.entity().downgrade(); // Per-composer key: the main and sidechat composers share this @@ -10223,9 +10319,6 @@ impl Composer { crate::theme::ink(0.10), )) }) - .when(t > 0.0, |el| { - glass::accent(el, &theme, t, glow).hover(|style| style.opacity(0.85)) - }) .focus_visible(|style| style.border_1().border_color(theme.accent)) .tooltip(crate::settings::widgets::text_tooltip(tooltip)) .on_mouse_down( @@ -10260,6 +10353,8 @@ impl Composer { .update(cx, |this, cx| this.toggle_dictation(true, cx)) .ok(); }) + // The microphone at rest; while the track runs, the same spot is + // the stop, and while the words are read, the working glyph. .when(t < 1.0, |el| { let scale = 1.0 - 0.75 * t; el.child( @@ -10273,34 +10368,49 @@ impl Composer { ), ) }) - .when(t > 0.0 && busy, |el| { - el.child( - centered() - .opacity(t) - .child(crate::loaders::mini_mono_spinner( - "dictation-progress", - 2.0, - theme.on_accent, - cx.entity_id(), - cx, - )), - ) - }) - .when(t > 0.0 && !busy, |el| { - // Square grows from a quarter of its size, as the mic shrinks. - let side = 9.0 * (0.25 + 0.75 * t) * if live { 1.0 + 0.08 * glow } else { 1.0 }; - el.child( - centered().child( - div() - .size(px(side)) - .rounded(px(2.5)) - .bg(theme.on_accent.opacity(t)), - ), - ) - }); + .when( + t > 0.0 && phase != crate::dictation::Phase::Finalizing, + |el| { + let scale = 0.25 + 0.75 * t; + el.child( + centered().child( + div() + .size(px(10.0 * scale)) + .rounded(px(2.0 * scale)) + .bg(theme.text.opacity(t)), + ), + ) + }, + ) + .when( + t > 0.0 && phase == crate::dictation::Phase::Finalizing, + |el| { + use crate::voice::glyph::{self, Grid, Mode, Outline}; + el.child(centered().child(glyph::field( + &self.voice_bands.spinner, + 6, + 6, + Grid::dense(2.2), + Mode::Thinking, + &glyph::inks(&theme), + Outline::Square, + None, + ))) + }, + ); Some(button.into_any_element()) } + /// The dictation track's one control: listening, it stops and keeps the + /// words; opening the microphone or transcribing, it cancels. + fn stop_dictation(&mut self, cx: &mut Context) { + if self.input.read(cx).dictation.phase == crate::dictation::Phase::Listening { + self.toggle_dictation(true, cx); + } else { + self.dismiss_dictation(cx); + } + } + fn dismiss_dictation(&mut self, cx: &mut Context) { self.input.update(cx, |input, cx| { input.cancel_dictation(); @@ -10353,66 +10463,73 @@ impl Composer { }) } + /// What dictation could not do, said in one line over the foot of the + /// transcript, just above the composer: it floats, so nothing below it + /// moves. The detail is its tooltip; the cross dismisses it. fn render_dictation_status(&self, cx: &mut Context) -> Option { - // Live dictation renders inside the composer; only outcomes that - // need reading (no speech, errors) appear below it. let phase = self.input.read(cx).dictation.phase.clone(); if phase.active() { return None; } let (title, detail) = phase.status()?; let (title, detail) = (title.to_owned(), detail.to_owned()); - let phase = &phase; let failed = matches!( phase, crate::dictation::Phase::Denied(_) | crate::dictation::Phase::Unavailable(_) | crate::dictation::Phase::Failed(_) ); - let theme = Theme::of(cx); + let theme = Theme::of(cx).clone(); + let label = format!("{title}. {detail}"); Some( - motion::fade_in( - "dictation-message-enter", - div() - .id("dictation-status") - .flex() - .items_start() - .gap(px(8.0)) - .px(px(12.0)) - .text_size(px(12.0)) - .line_height(px(18.0)) - .child( - div() - .flex_none() - .mt(px(6.0)) - .size(px(6.0)) - .rounded_full() - .bg(if failed { - theme.warning - } else { - theme.text_faint - }), - ) - .child( - div() - .id("dictation-live-status") - .role(Role::Status) - .aria_label(format!("{title}. {detail}")) - .flex_1() - .min_w_0() - .flex() - .flex_wrap() - .gap_x(px(8.0)) - .child(div().text_color(theme.text).child(title)) - .child(div().min_w_0().text_color(theme.text_muted).child(detail)), - ) - .child( - self.dictation_action_button(false, cx) - .mt(px(-3.0)) - .child("Dismiss"), - ), - ) - .into_any_element(), + div() + .absolute() + .left(px(Theme::SPACE_SM)) + .bottom(gpui::relative(1.0)) + .pb(px(Theme::SPACE_SM)) + .child(motion::fade_in( + "dictation-message-enter", + div() + .id("dictation-status") + .role(Role::Status) + .aria_label(label) + .flex() + .items_center() + .gap(px(6.0)) + .h(px(26.0)) + .pl(px(10.0)) + .pr(px(4.0)) + .rounded_full() + .bg(theme.surface_raised) + .border_1() + .border_color(theme.border) + .text_size(px(12.0)) + .tooltip(crate::settings::widgets::text_tooltip(detail)) + .child( + div() + .flex_none() + .size(px(6.0)) + .rounded_full() + .bg(if failed { + theme.warning + } else { + theme.text_faint + }), + ) + .child(div().text_color(theme.text_muted).child(title)) + .child( + self.dictation_action_button(false, cx) + .size(px(20.0)) + .px_0() + .justify_center() + .child( + crate::icons::icon(crate::icons::CLOSE) + .size(px(12.0)) + .text_color(theme.text_faint), + ), + ), + )) + .into_any_element(), ) } @@ -10439,19 +10556,25 @@ impl Composer { .status() .map(|(title, detail)| format!("{title}. {detail}")) .unwrap_or_default(); + let level = if mode == Mode::Live { + meter.level(now) + } else { + 0.0 + }; self.voice_last = Some(VoiceFrame { label, mode, - bars: meter.bars(now, !reduced), - level: if mode == Mode::Live { - meter.level(now) - } else { - 0.0 - }, elapsed: meter.since_start(now).map(|_| meter.elapsed(now)), }); - } else if t <= 0.0 { - self.voice_last = None; + if !reduced { + self.voice_bands.hear(mode == Mode::Live, level, now); + self.voice_bands.beam.shift(mode.glyph()); + } + } else { + self.voice_bands.hear(false, 0.0, now); + if t <= 0.0 { + self.voice_last = None; + } } if !reduced && (active || !self.voice_tween.settled(now)) { // Bars scroll, the glow follows the voice, and the morph advances. @@ -10473,8 +10596,8 @@ impl Composer { cx: &mut Context, ) -> gpui::AnyElement { use crate::dictation::{glass, waveform}; + use crate::voice::glyph::{self, Grid}; let theme = Theme::of(cx).clone(); - let animate = !motion::reduced_motion(cx); let active = self.input.read(cx).dictation.phase.active(); let live = frame.mode == waveform::Mode::Live && active; let clock = frame.elapsed.map(|elapsed| { @@ -10499,41 +10622,60 @@ impl Composer { .role(Role::Status) .aria_label(frame.label.clone()) .h_full() + .relative() .w(gpui::relative(t.max(0.0))) .min_w(px(VOICE_TRACK_HEIGHT * t.min(1.0))) .rounded_full() .overflow_hidden() .flex() .items_center() - .gap(px(10.0)) - .pl(px(12.0)) + .justify_end() .pr(px(12.0)), &theme, t, ) + // The beam fills the track to its rim, round at its start, and + // rises from its bottom as the voice does; it dissolves into the + // clock rather than stopping at it. Closing, it goes out first and + // the track then folds away. .child( div() - .flex_1() - .min_w_0() - .h(px(16.0)) - .flex() - .child(waveform::waveform( - frame.bars.clone(), - frame.mode, - waveform::Paint { - ink: theme.text.opacity(0.8), - quiet: theme.text_faint.opacity(0.5), - }, - waveform::Phase { - intro: t, - collapse: if active { 0.0 } else { 1.0 - t }, - }, - animate, - )), + .absolute() + .top_0() + .bottom_0() + .left_0() + .right(px(VOICE_CLOCK_ROOM)) + .opacity(if active { + t + } else { + VOICE_CURVE.eval(((t - 0.45) / 0.55).clamp(0.0, 1.0)) + }) + .child( + crate::edge_fade::edge_faded( + VOICE_BEAM_FADE, + false, + false, + glyph::beam( + &self.voice_bands.beam, + Grid::dense(glyph::BEAM_DOT), + frame.mode.glyph(), + glyph::inks(&theme), + glyph::Outline::Stadium, + ) + .size_full(), + ) + .fade_right(true), + ), ) .children(clock); div() .id("dictation-track") + // A hold that began on the microphone ends wherever the pointer + // lets go, the track now covering it included. + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, _, cx| this.release_dictation(HoldSource::Pointer, cx)), + ) .absolute() .left(px(left)) .right(px(right)) @@ -10722,7 +10864,10 @@ impl Render for Composer { let input = self.input.read(cx); ( input.measured_text_width(), - input.has_newline(), + // Live dictation opens the composer up: its words stay in + // view above the track instead of under it. + input.has_newline() + || (input.dictation.phase.active() && input.dictation.has_partial), input.measured_content_height(), input.last_width, input.layout_epoch, @@ -11143,7 +11288,6 @@ impl Render for Composer { let send_button = self.render_send_button(mode, cx); let (voice_t, voice_frame) = self.update_voice(window, cx); - let dictating = self.input.read(cx).dictation.phase.active(); let microphone = self.render_dictation_button(voice_t, voice_frame.as_ref(), cx); let attach_action = cx.entity().downgrade(); let attach_hover_key: SharedString = format!("composer-attach-{}", cx.entity_id()).into(); @@ -11168,94 +11312,41 @@ impl Render for Composer { )) .on_hover(motion::hover_listener(attach_hover_key)) .relative() - // While dictating, the attachment slot becomes Cancel: the - // paperclip and the cross swap with a scale and fade. The action - // follows the dictation phase, never the animation. + // The paperclip stays the paperclip while dictating: the track + // runs beside it, and letting go ends the dictation on its own. .role(Role::Button) - .aria_label(if dictating { - "Cancel dictation" - } else { - "Attach images" - }) + .aria_label("Attach images") .tab_index(0) .focus_visible(|style| style.border_1().border_color(theme.accent)) - .when(dictating, |el| { - el.debug_selector(|| "dictation-dismiss".into()) - }) - .tooltip(crate::settings::widgets::text_tooltip(if dictating { - "Cancel dictation" - } else { - "Attach images" - })) - .on_click(cx.listener(move |this, _, _, cx| { - if this.input.read(cx).dictation.phase.active() { - this.dismiss_dictation(cx); - } else { - this.open_file_picker(cx); - } - })) + .tooltip(crate::settings::widgets::text_tooltip("Attach images")) + .on_click(cx.listener(move |this, _, _, cx| this.open_file_picker(cx))) .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| { if matches!(event.keystroke.key.as_str(), "enter" | "space") { cx.stop_propagation(); - if this.input.read(cx).dictation.phase.active() { - this.dismiss_dictation(cx); - } else { - this.open_file_picker(cx); - } + this.open_file_picker(cx); } })) .on_a11y_action(gpui::AccessibleAction::Click, move |_, _, cx| { attach_action - .update(cx, |this, cx| { - if this.input.read(cx).dictation.phase.active() { - this.dismiss_dictation(cx); - } else { - this.open_file_picker(cx); - } - }) + .update(cx, |this, cx| this.open_file_picker(cx)) .ok(); }) - .when(voice_t < 1.0, |el| { - let scale = 1.0 - 0.75 * voice_t; - el.child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .child( - crate::icons::icon(crate::icons::PAPERCLIP) - // Its painted bounds are centered in the 24px viewbox; - // a larger glyph balances the brand icon without moving - // it off-center inside the unchanged 28px hit target. - .size(px(18.0)) - .text_color(theme.text_muted.opacity(1.0 - voice_t)) - .with_transformation(gpui::Transformation::scale(gpui::size( - scale, scale, - ))), - ), - ) - }) - .when(voice_t > 0.0, |el| { - let scale = 0.25 + 0.75 * voice_t; - el.child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .child( - crate::icons::icon(crate::icons::CLOSE) - .size(px(16.0)) - .text_color(theme.text_muted.opacity(voice_t)) - .with_transformation(gpui::Transformation::scale(gpui::size( - scale, scale, - ))), - ), - ) - }); + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + crate::icons::icon(crate::icons::PAPERCLIP) + // Its painted bounds are centered in the 24px viewbox; + // a larger glyph balances the brand icon without moving + // it off-center inside the unchanged 28px hit target. + .size(px(18.0)) + .text_color(theme.text_muted), + ), + ); // Staged-thumbnail strip (attachment-ui.tsx AttachmentStrip), above // the input inside the pill in both modes. let strip = self.render_attachment_strip(&theme, cx); @@ -11336,6 +11427,8 @@ impl Render for Composer { } else { (COMPACT_TOTAL_HEIGHT - PILL_BORDER_V - VOICE_TRACK_HEIGHT) / 2.0 - cluster_dy }; + // It runs from the cross to Send, over the microphone: the + // cross is the dictation's only control. self.render_voice_track( voice_t, frame, @@ -11579,9 +11672,7 @@ impl Render for Composer { cx.stop_propagation(); } })) - .flex() - .flex_col() - .gap(px(Theme::SPACE_SM)) + .relative() .child(pill_surface) .children(dictation_status), ); diff --git a/crates/ui/src/composer_dictation_tests.rs b/crates/ui/src/composer_dictation_tests.rs index 30e0f19914..3f2ee9c2ae 100644 --- a/crates/ui/src/composer_dictation_tests.rs +++ b/crates/ui/src/composer_dictation_tests.rs @@ -506,12 +506,13 @@ fn dictation_pointer_hold_finishes_on_release_even_outside_the_button(cx: &mut T cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) .unwrap(); let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); + // The hold began on the microphone, which the track now covers. let button = visual.debug_bounds("composer-dictation").unwrap(); - visual.simulate_mouse_down( - button.center(), - MouseButton::Left, - gpui::Modifiers::default(), - ); + handle + .update(&mut visual, |composer, _, cx| { + composer.press_dictation(super::HoldSource::Pointer, cx) + }) + .unwrap(); assert_eq!(fake.borrow().finishes, 0, "holding keeps recording"); let up = if release_outside { button.origin - point(px(40.0), px(40.0)) @@ -678,9 +679,13 @@ fn dictation_pointer_stop_keeps_capture_until_final_result(cx: &mut TestAppConte .unwrap(); cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) .unwrap(); - let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); - let button = visual.debug_bounds("composer-dictation").unwrap(); - visual.simulate_click(button.center(), gpui::Modifiers::default()); + // Letting go of the microphone is Stop. + handle + .update(cx, |composer, _, cx| { + composer.press_dictation(HoldSource::Pointer, cx); + composer.release_dictation(HoldSource::Pointer, cx); + }) + .unwrap(); assert_eq!( fake.borrow().drops, 0, @@ -834,76 +839,6 @@ fn dictation_focus_within_composer_keeps_capture_but_leaving_cancels(cx: &mut Te .unwrap(); } -#[gpui::test] -fn dictation_cancel_control_preserves_draft_and_clears_pending_send(cx: &mut TestAppContext) { - let (_dir, handle) = super::tests::composer_focus_window(cx); - let (input, fake) = handle - .update(cx, |composer, _, cx| { - let fake = composer.input.update(cx, |input, cx| { - input.set_text("Keep this draft", cx); - let fake = start(input, cx); - deliver(input, &fake, [Event::Listening], cx); - input.finish_dictation(true, cx); - fake - }); - (composer.input.clone(), fake) - }) - .unwrap(); - let mut events = cx.events::(&input); - cx.update_window(handle.into(), |_, window, cx| window.draw(cx).clear()) - .unwrap(); - let mut visual = gpui::VisualTestContext::from_window(handle.into(), cx); - let cancel = visual.debug_bounds("dictation-dismiss").unwrap(); - visual.simulate_click(cancel.center(), gpui::Modifiers::default()); - assert_eq!(fake.borrow().drops, 1); - input.read_with(cx, |input, _| { - assert_eq!(input.text(), "Keep this draft"); - assert_eq!(input.dictation.phase, Phase::Idle); - }); - while let Ok(event) = events.try_recv() { - assert!(!matches!(event, DictationInputEvent::Submit(_))); - } -} - -#[gpui::test] -fn dictation_cancel_control_is_keyboard_operable(cx: &mut TestAppContext) { - let (_dir, handle) = super::tests::composer_focus_window(cx); - let fake = handle - .update(cx, |composer, window, cx| { - let fake = composer.input.update(cx, |input, cx| { - input.set_text("Keep this draft", cx); - let fake = start(input, cx); - deliver(input, &fake, [Event::Listening], cx); - fake - }); - window.focus(&composer.input.focus_handle(cx), cx); - fake - }) - .unwrap(); - cx.update_window(handle.into(), |_, window, cx| { - window.draw(cx).clear(); - window.focus_next(cx); - window.draw(cx).clear(); - }) - .unwrap(); - cx.run_until_parked(); - assert_eq!( - fake.borrow().drops, - 0, - "tabbing to Cancel must retain capture" - ); - cx.simulate_keystrokes(handle.into(), "space"); - cx.run_until_parked(); - assert_eq!(fake.borrow().drops, 1); - assert_eq!(fake.borrow().finishes, 0, "Cancel must not transcribe"); - handle - .read_with(cx, |composer, cx| { - assert_eq!(composer.input.read(cx).text(), "Keep this draft"); - assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); - }) - .unwrap(); -} - #[gpui::test] fn dictation_keeps_draft_stable_when_pasted_references_resolve(cx: &mut TestAppContext) { let input = cx.new(|cx| ComposerInput::new("Draft", cx)); @@ -1197,3 +1132,41 @@ fn dictation_shortcut_falls_through_while_dictation_is_off(cx: &mut TestAppConte }) .unwrap(); } + +#[gpui::test] +fn a_voice_call_takes_the_microphone_and_muting_it_gives_it_back(cx: &mut TestAppContext) { + let (dir, handle) = super::tests::composer_focus_window(cx); + enable_dictation(dir.path(), cx); + let fake = handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + let fake = start(input, cx); + deliver(input, &fake, [Event::Listening], cx); + fake + }) + }) + .unwrap(); + // A call starts listening: dictation ends, keeping what was said. + cx.update(|cx| crate::voice::publish_listening(true, cx)); + cx.run_until_parked(); + assert_eq!(fake.borrow().drops, 0); + assert_eq!(fake.borrow().finishes, 1); + handle + .update(cx, |composer, _, cx| { + composer.input.update(cx, |input, cx| { + deliver(input, &fake, [Event::Final("Bonjour".into())], cx); + }); + // While it listens, dictation is not offered at all. + assert!(composer.render_dictation_button(0.0, None, cx).is_none()); + composer.toggle_dictation(false, cx); + assert_eq!(composer.input.read(cx).dictation.phase, Phase::Idle); + }) + .unwrap(); + // Muted, the call hands the microphone back. + cx.update(|cx| crate::voice::publish_listening(false, cx)); + handle + .update(cx, |composer, _, cx| { + assert!(composer.render_dictation_button(0.0, None, cx).is_some()); + }) + .unwrap(); +} diff --git a/crates/ui/src/dictation.rs b/crates/ui/src/dictation.rs index b9b7d40d34..26994e3e0a 100644 --- a/crates/ui/src/dictation.rs +++ b/crates/ui/src/dictation.rs @@ -7,7 +7,7 @@ pub(crate) mod glass; mod meter; mod model; pub(crate) mod waveform; -pub(crate) use meter::{Bar, FLOOR, Meter}; +pub(crate) use meter::Meter; pub(crate) use model::{card, enabled, init}; pub(crate) const FINALIZE_TIMEOUT: Duration = Duration::from_secs(30); @@ -109,6 +109,7 @@ impl Transcriber for Native { } self.session.as_mut()?.poll().map(|event| match event { zeron_voice::Event::Listening => Event::Listening, + zeron_voice::Event::Partial(t) => Event::Partial(t), zeron_voice::Event::Finalizing => Event::Finalizing, zeron_voice::Event::Final(t) => Event::Final(t), zeron_voice::Event::Failed(e) => Event::Failed(e), @@ -126,7 +127,65 @@ impl Transcriber for Native { } } } +/// A dictation made up, for looking at its surfaces without a microphone or a +/// model: `ZERON_DICTATION_PREVIEW=1` in a debug build. Words stream in as a +/// live transcript does, over a voice that rises and falls, until stopped. +#[cfg(debug_assertions)] +#[derive(Default)] +struct Scripted { + started: Option, + said: usize, + listening: bool, + /// When it was let go: the final comes a moment later, as a model's does. + finished: Option, +} + +#[cfg(debug_assertions)] +impl Scripted { + const WORDS: &'static str = "Run the parser tests and open a pull request once they pass"; +} + +#[cfg(debug_assertions)] +impl Transcriber for Scripted { + fn poll(&mut self) -> Option { + let started = *self.started.get_or_insert_with(std::time::Instant::now); + if !self.listening { + self.listening = true; + return Some(Event::Listening); + } + let words: Vec<&str> = Self::WORDS.split(' ').collect(); + if let Some(at) = self.finished { + if at.elapsed() < std::time::Duration::from_millis(1500) { + return None; + } + self.finished = None; + return Some(Event::Final(words[..self.said.max(1)].join(" "))); + } + let due = ((started.elapsed().as_secs_f32() * 2.5) as usize).min(words.len()); + (due > self.said).then(|| { + self.said = due; + Event::Partial(words[..due].join(" ")) + }) + } + + fn finish(&mut self) { + self.finished = Some(std::time::Instant::now()); + } + + fn level(&mut self) -> f32 { + if self.finished.is_some() { + return 0.0; + } + let t = self.started.map_or(0.0, |at| at.elapsed().as_secs_f32()); + 0.02 + 0.06 * (t * 4.7 * std::f32::consts::TAU).sin().max(0.0) + } +} + pub(crate) fn start(cx: &gpui::App) -> Option> { + #[cfg(debug_assertions)] + if std::env::var_os("ZERON_DICTATION_PREVIEW").is_some() { + return Some(Box::new(Scripted::default())); + } if !enabled(cx) { return None; } @@ -171,8 +230,8 @@ impl Phase { pub fn action_label(&self) -> &'static str { match self { Self::Idle | Self::Tapped => "Hold to dictate", - Self::Requesting | Self::Listening => "Release to transcribe", - Self::Finalizing => "Transcribing", + Self::Requesting | Self::Listening => "Stop dictation", + Self::Finalizing => "Cancel transcription", Self::NoSpeech | Self::Denied(_) | Self::Unavailable(_) | Self::Failed(_) => { "Hold to retry dictation" } diff --git a/crates/ui/src/dictation/glass.rs b/crates/ui/src/dictation/glass.rs index e39eae3cc0..22a0cdf4e0 100644 --- a/crates/ui/src/dictation/glass.rs +++ b/crates/ui/src/dictation/glass.rs @@ -1,5 +1,4 @@ -//! Glass surfaces for the voice controls: a light plate for the waveform track -//! and an accent plate for Stop. Each is a vertical gradient (lit from above), +//! The glass surface of the dictation track: a vertical gradient (lit from above), //! a hairline rim, an inner top highlight, and a soft drop. Dark appearances //! keep the structure with translucent white instead of opaque fills. use gpui::{ @@ -34,11 +33,6 @@ fn vertical(top: Hsla, bottom: Hsla) -> Background { ) } -/// A lighter tint of `color` for the lit top of a gradient. -fn lift(color: Hsla, amount: f32) -> Hsla { - crate::motion::mix(color, white(color.a), amount) -} - /// Neutral glass plate. `t` fades the whole treatment in with the morph. pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { let dark = theme.appearance.is_dark(); @@ -67,23 +61,3 @@ pub(crate) fn light(el: E, theme: &Theme, t: f32) -> E { shadow(drop.opacity(t), 1.0, 3.0, 0.0, false), ]) } - -/// Accent glass plate. `t` fades it in over the resting control; `glow` -/// (0–1, the live voice level) spreads its coloured shadow. -pub(crate) fn accent(el: E, theme: &Theme, t: f32, glow: f32) -> E { - let dark = theme.appearance.is_dark(); - let base = theme.accent; - let top = lift(base, if dark { 0.18 } else { 0.32 }); - let bottom = base; - let rim = lift(base, 0.45).opacity(if dark { 0.45 } else { 0.7 }); - let highlight = white(if dark { 0.22 } else { 0.38 }); - let halo = base.opacity((0.22 + 0.4 * glow) * t); - el.bg(vertical(top.opacity(t), bottom.opacity(t))) - .border_1() - .border_color(rim.opacity(t)) - .shadow(vec![ - shadow(highlight.opacity(t), 1.0, 0.0, 0.0, true), - shadow(white(0.12 * t), 0.0, 0.0, 1.0, true), - shadow(halo, 2.0 + 2.0 * glow, 6.0 + 14.0 * glow, 0.0, false), - ]) -} diff --git a/crates/ui/src/dictation/meter.rs b/crates/ui/src/dictation/meter.rs index 7ddf7152f5..0a72c9cdc2 100644 --- a/crates/ui/src/dictation/meter.rs +++ b/crates/ui/src/dictation/meter.rs @@ -16,8 +16,6 @@ const CAPACITY: usize = 320; /// A bar never drops below its neighbour faster than this, so syllables leave /// a short tail instead of flickering between loud and silent. const RELEASE: f32 = 0.62; -/// The quietest bar still reads as a dot on the baseline. -pub(crate) const FLOOR: f32 = 0.08; #[derive(Debug, Default, Clone)] pub(crate) struct Meter { diff --git a/crates/ui/src/dictation/model.rs b/crates/ui/src/dictation/model.rs index 681a29999b..f81fe0715c 100644 --- a/crates/ui/src/dictation/model.rs +++ b/crates/ui/src/dictation/model.rs @@ -18,7 +18,7 @@ struct VoiceGlobal { } impl Global for VoiceGlobal {} pub(crate) fn init(root: PathBuf, cx: &mut App) { - let directory = root.join("models/parakeet-tdt-0.6b-v3-int8"); + let directory = root.join(zeron_voice::MODEL_DIR); let card = cx.new(|_| VoiceCard { scroll: widgets::PageScroll::default(), ready: zeron_voice::installed(&directory), @@ -46,6 +46,11 @@ pub(crate) fn directory(cx: &App) -> PathBuf { cx.global::().directory.clone() } pub(crate) fn enabled(cx: &App) -> bool { + // A scripted dictation needs no model (see `dictation::start`). + #[cfg(debug_assertions)] + if std::env::var_os("ZERON_DICTATION_PREVIEW").is_some() { + return true; + } settings::current(cx).dictation_enabled && cx .try_global::() diff --git a/crates/ui/src/dictation/waveform.rs b/crates/ui/src/dictation/waveform.rs index 8dfe87ef3e..3ae58eeb72 100644 --- a/crates/ui/src/dictation/waveform.rs +++ b/crates/ui/src/dictation/waveform.rs @@ -1,136 +1,26 @@ -//! Painted dictation waveform: one rounded bar per meter slot, scrolling from -//! the trailing edge. A single canvas keeps a full-width waveform to one -//! element and a handful of quads per frame. -use std::time::Instant; - -use gpui::{Bounds, Hsla, IntoElement, Styled, point, px, size}; - -use super::{Bar, FLOOR}; - -pub(crate) const BAR_WIDTH: f32 = 3.0; -const BAR_GAP: f32 = 3.0; -const PITCH: f32 = BAR_WIDTH + BAR_GAP; -/// Bars dissolve over this distance at the leading edge instead of clipping. -const EDGE_FADE: f32 = 28.0; -/// Seconds for the transcribing highlight to cross the bars once. -const SWEEP_PERIOD: f32 = 1.6; -/// Seconds per breath of the placeholder baseline while capture starts. -const BREATH_PERIOD: f32 = 1.4; +//! The dictation track's phases and its clock. The track itself is the voice +//! glyph's beam ([`crate::voice::glyph`]), drawn by the composer. #[derive(Clone, Copy, PartialEq)] pub(crate) enum Mode { - /// Microphone is opening: a quiet baseline breathes. + /// Microphone is opening. Waiting, - /// Live input: bars follow the voice. + /// Live input: the beam follows the voice. Live, - /// Capture ended: bars hold still while a highlight sweeps across them. + /// Capture ended: a highlight travels across while it is transcribed. Processing, } -pub(crate) struct Paint { - pub ink: Hsla, - pub quiet: Hsla, -} - -/// A process-wide clock so repeated renders stay phase-continuous. -pub(crate) fn seconds() -> f32 { - static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); - EPOCH.get_or_init(Instant::now).elapsed().as_secs_f32() -} - -pub(crate) fn ease_out(t: f32) -> f32 { - let t = t.clamp(0.0, 1.0); - 1.0 - (1.0 - t).powi(3) -} - -/// Entrance and exit progress, each 0–1. -#[derive(Clone, Copy, Default)] -pub(crate) struct Phase { - /// Baseline draws in from the trailing edge as the pill appears. - pub intro: f32, - /// Bars settle back into the baseline as the transcript lands. - pub collapse: f32, -} - -/// Bars ride a fixed grid of dots, so live input rises out of the same -/// baseline the waiting state shows. `motion` false freezes the waiting -/// breath and the processing sweep; the indicator still names the state. -pub(crate) fn waveform( - bars: Vec, - mode: Mode, - paint: Paint, - phase: Phase, - motion: bool, -) -> impl IntoElement { - let now = if motion { seconds() } else { 0.0 }; - gpui::canvas( - |_, _, _| {}, - move |bounds, _, window, _| { - let height = f32::from(bounds.size.height); - let left = f32::from(bounds.left()); - let right = f32::from(bounds.right()) - BAR_WIDTH; - let mid = f32::from(bounds.center().y); - let width = (right - left).max(1.0); - // Newest slot's scroll position; the dots scroll with the bars. - let offset = match mode { - Mode::Waiting => 0.0, - _ => bars.first().map_or(0.0, |bar| bar.age.fract()), - }; - let count = ((width + BAR_WIDTH) / PITCH).ceil() as usize + 1; - let sweep = (now / SWEEP_PERIOD).fract(); - let settle = 1.0 - ease_out(phase.collapse); - for k in 0..count { - let x = right - (k as f32 + offset) * PITCH; - let fade = ((x - left) / EDGE_FADE).clamp(0.0, 1.0); - // Draw in from the trailing edge: 0 there, 1 at the leading edge. - let distance = 1.0 - (x - left) / width; - let reveal = ease_out((phase.intro - 0.5 * distance) / 0.5); - let alpha = fade * reveal; - if alpha <= 0.0 { - continue; - } - let (amplitude, color) = match (mode, bars.get(k)) { - (Mode::Waiting, _) | (_, None) => { - // A soft swell travels toward the leading edge. - let swell = if mode == Mode::Waiting { - let wave = now / BREATH_PERIOD - k as f32 / 18.0; - (0.5 + 0.5 * (wave * std::f32::consts::TAU).sin()).powi(3) - } else { - 0.0 - }; - (FLOOR + 0.1 * swell, paint.quiet) - } - (_, Some(bar)) => { - // The newest bar rises out of the baseline as it enters. - let grow = if motion { ease_out(bar.age) } else { 1.0 }; - let color = if mode == Mode::Live { - paint.ink - } else { - // Right-to-left highlight, matching the scroll. - let position = 1.0 - (x - left) / width; - let distance = (position - (sweep * 1.4 - 0.2)).abs(); - let glow = (1.0 - distance / 0.18).clamp(0.0, 1.0); - crate::motion::mix(paint.quiet, paint.ink, ease_out(glow)) - }; - let lift = (1.0 - FLOOR) * bar.amplitude * grow * settle; - (FLOOR + lift, crate::motion::mix(paint.quiet, color, settle)) - } - }; - let h = - (amplitude.clamp(FLOOR, 1.0) * height * (0.4 + 0.6 * reveal)).max(BAR_WIDTH); - window.paint_quad( - gpui::fill( - Bounds::new(point(px(x), px(mid - h / 2.0)), size(px(BAR_WIDTH), px(h))), - color.opacity(alpha), - ) - .corner_radii(px(BAR_WIDTH / 2.0)), - ); - } - }, - ) - .flex_1() - .min_w_0() - .h_full() +impl Mode { + /// What the glyph shows in this phase. + pub(crate) fn glyph(self) -> crate::voice::glyph::Mode { + use crate::voice::glyph::Mode as Glyph; + match self { + Self::Waiting => Glyph::Connecting, + Self::Live => Glyph::Listening, + Self::Processing => Glyph::Processing, + } + } } /// Display time for the recording clock: `0:07`. diff --git a/crates/ui/src/icons.rs b/crates/ui/src/icons.rs index 2452768cd4..599d55a227 100644 --- a/crates/ui/src/icons.rs +++ b/crates/ui/src/icons.rs @@ -184,6 +184,7 @@ icon_assets![ (VOLUME_LOUD, "volume-loud"), // Hand-drawn microphone pair in the Solar Linear style — voice controls. (MICROPHONE, "microphone"), + (VOICE_WAVE, "voice-wave"), (MICROPHONE_OFF, "microphone-off"), (PHONE_HANG_UP, "phone-hang-up"), // Hand-drawn zeron glyphs (terminal-panel.tsx / composer-actions.tsx / diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index b9d1eca902..7f84be63cb 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -50,7 +50,6 @@ mod new_thread_background_image; mod new_thread_background_mask; mod notice; pub mod notify; -pub mod orb; pub mod overscroll; pub mod pickers; pub mod popover; diff --git a/crates/ui/src/orb/mod.rs b/crates/ui/src/orb/mod.rs deleted file mode 100644 index baa22ac3e8..0000000000 --- a/crates/ui/src/orb/mod.rs +++ /dev/null @@ -1,19 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Thinking orbs — dotted loading indicators for AI & agent UIs, ported from -//! gpui-thinking-orbs (MIT), itself a port of Jakub Antalik's thinking-orbs. -//! -//! The animation engine and playback live in `zeron-orb`, shared with the -//! mobile apps; this module is its GPUI widget and paint path. -//! -//! ```ignore -//! cx.new(|_| Orb::new().state(OrbState::Searching).size(OrbSize::Avatar)) -//! ``` -//! -//! Style flows through the environment like the rest of bezel: [`OrbTheme::Auto`] -//! resolves ink against the installed [`theme::Appearance`] at paint time. - -mod orb; -mod paint; - -pub use orb::{DEFAULT_TARGET_FPS, Orb, orb_element}; -pub use zeron_orb::{ModeKey, OrbSize, OrbState, OrbTheme, Resolved, engine, resolve_preset}; diff --git a/crates/ui/src/orb/orb.rs b/crates/ui/src/orb/orb.rs deleted file mode 100644 index 33820d2a30..0000000000 --- a/crates/ui/src/orb/orb.rs +++ /dev/null @@ -1,451 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! The [`Orb`] component: a Rust builder over the animation engine. -//! -//! The engine takes continuous, unbounded time — its modes mix incommensurate -//! frequencies, so there is no seamless wrap point and gpui's folded -//! `with_animation` clock would jump at every loop. An entity owns the real -//! clock; [`orb_element`] is the same paint one layer down for hosts that -//! already tick. - -use std::{cell::RefCell, rc::Rc, time::Duration}; - -use gpui::{ - Bounds, Context, IntoElement, ParentElement, Pixels, Render, Styled, Task, Window, canvas, div, - px, -}; -use std::time::Instant; -use zeron_orb::{ - OrbAnimator, OrbSize, OrbState, OrbTheme, - engine::{Frame, draw_mode_into}, - resolve_preset, -}; - -use crate::orb::paint::paint_frame; - -/// Frames per second the orb redraws at when no explicit rate is set. -/// -/// The orb is a status indicator, not a game: its motion is slow and organic, -/// and at 30 fps it is indistinguishable from 60 while costing half as much. -/// Every redraw walks the whole element tree, so the tick rate — not the -/// geometry — is what dominates CPU. -pub const DEFAULT_TARGET_FPS: f32 = 30.0; - -/// Animated thinking-orb status indicator for AI / agent UIs. -/// -/// ```ignore -/// cx.new(|_| Orb::new().state(OrbState::Searching).size(OrbSize::Avatar)) -/// ``` -pub struct Orb { - /// State, size, speed, clock, audio and crossfades — shared with mobile. - animator: OrbAnimator, - /// Uniform magnification of the size preset's painted geometry. - scale: f32, - theme: OrbTheme, - paused: bool, - /// When true, freeze on a static representative frame (`t = 0.6`). The - /// system `reduce_motion` setting forces the same, so hosts only set this - /// for their own per-surface motion preferences. - reduced_motion: bool, - /// Redraw rate ceiling. - target_fps: f32, - /// Stop animating while the host window is not the active one. - pause_when_inactive: bool, - /// Host-controlled visibility. When false the timer is cancelled and the - /// orb freezes — use this when the entity is still mounted but scrolled - /// off-screen (gpui has no intersection observer). - visible: bool, - - /// Geometry buffer reused across frames. Behind `Rc>` because - /// the canvas paint callback must be `'static` and so cannot borrow `self`. - frame: Rc>, - /// Explicit invalidation for animation ticks and semantic changes. Parent - /// renders between ticks reuse the retained geometry. - geometry_dirty: bool, - - /// Pending redraw timer. At most one stays in flight; dropping it cancels - /// animation immediately when the orb becomes paused or invisible. - tick: Option>, - /// Window-activation subscription, registered lazily on first render since - /// it needs a `Window`. Dropping it unsubscribes. - activation: Option, -} - -impl Default for Orb { - fn default() -> Self { - Self::new() - } -} - -impl Orb { - pub fn new() -> Self { - Self { - animator: OrbAnimator::new(OrbState::Working, OrbSize::Avatar), - scale: 1.0, - theme: OrbTheme::Auto, - paused: false, - reduced_motion: false, - target_fps: DEFAULT_TARGET_FPS, - pause_when_inactive: true, - visible: true, - frame: Rc::new(RefCell::new(Frame::new())), - geometry_dirty: true, - tick: None, - activation: None, - } - } - - pub fn state(mut self, state: OrbState) -> Self { - // Nothing is on screen yet, so there is no frame to fade from. - self.animator.set_state(state, &Frame::new()); - self - } - - pub fn size(mut self, size: OrbSize) -> Self { - self.animator.set_size(size); - self - } - - /// Paint the size preset magnified, e.g. a hero orb filling a stage. - pub fn scale(mut self, scale: f32) -> Self { - self.scale = sanitize_scale(scale); - self - } - - pub fn theme(mut self, theme: OrbTheme) -> Self { - self.theme = theme; - self - } - - pub fn speed(mut self, speed: f32) -> Self { - self.animator.set_speed(speed); - self - } - - /// Freeze the accumulated animation time while paused. - pub fn paused(mut self, paused: bool) -> Self { - self.paused = paused; - self - } - - /// Crossfade state geometry without remounting or restarting the clock. - pub fn state_transition(mut self, duration: Duration) -> Self { - self.animator.set_transition(duration); - self - } - - pub fn reduced_motion(mut self, reduced: bool) -> Self { - self.reduced_motion = reduced; - self - } - - /// Cap the redraw rate. Values are clamped to `1.0..=240.0`. - /// - /// Lower is cheaper: cost scales linearly with this number. - pub fn target_fps(mut self, fps: f32) -> Self { - self.target_fps = sanitize_fps(fps); - self - } - - /// Whether to freeze while the host window is inactive. Defaults to `true`. - /// - /// A background window's animation is not visible to anyone, so this is - /// usually free. Set it to `false` if the orb must keep moving in a window - /// that is visible but unfocused — a side panel, or a floating HUD. - pub fn pause_when_inactive(mut self, pause: bool) -> Self { - self.pause_when_inactive = pause; - self - } - - /// Whether the host considers this orb on-screen. Defaults to `true`. - /// - /// gpui has no intersection observer. When you keep the entity mounted in - /// a scrollable list but it has scrolled away, call - /// [`Self::set_visible`]`(false)` (or build with `.visible(false)`) so the - /// timer stops. Prefer unmounting when you can. - pub fn visible(mut self, visible: bool) -> Self { - self.visible = visible; - self - } - - /// Mutable setters for interactive playgrounds. - pub fn set_state(&mut self, state: OrbState, cx: &mut Context) { - // A hidden, paused or reduced orb has a stopped clock, so it switches - // without a fade. - if self.animator.set_state(state, &self.frame.borrow()) { - self.geometry_dirty = true; - cx.notify(); - } - } - - pub fn set_size(&mut self, size: OrbSize, cx: &mut Context) { - if self.animator.set_size(size) { - self.geometry_dirty = true; - cx.notify(); - } - } - - pub fn set_scale(&mut self, scale: f32, cx: &mut Context) { - let scale = sanitize_scale(scale); - if (self.scale - scale).abs() > f32::EPSILON { - self.scale = scale; - cx.notify(); - } - } - - pub fn set_theme(&mut self, theme: OrbTheme, cx: &mut Context) { - if self.theme != theme { - self.theme = theme; - cx.notify(); - } - } - - pub fn set_speed(&mut self, speed: f32, cx: &mut Context) { - if self.animator.set_speed(speed) { - self.geometry_dirty = true; - cx.notify(); - } - } - - /// Audio affects only visual motion, with independent microphone and - /// speaker envelopes evaluated at the orb's frame rate. - pub fn set_audio_levels(&mut self, microphone: f32, speaker: f32, cx: &mut Context) { - if self - .animator - .set_audio_levels(microphone, speaker, Instant::now()) - { - self.geometry_dirty = true; - cx.notify(); - } - } - - pub fn set_paused(&mut self, paused: bool, cx: &mut Context) { - if self.paused == paused { - return; - } - self.paused = paused; - if paused { - self.animator.stop(Instant::now()); - } - self.geometry_dirty = true; - cx.notify(); - } - - pub fn set_reduced_motion(&mut self, reduced: bool, cx: &mut Context) { - if self.reduced_motion != reduced { - self.reduced_motion = reduced; - if reduced { - self.animator.stop(Instant::now()); - self.animator.cancel_transition(); - } - self.geometry_dirty = true; - cx.notify(); - } - } - - pub fn set_target_fps(&mut self, fps: f32, cx: &mut Context) { - self.target_fps = sanitize_fps(fps); - cx.notify(); - } - - pub fn set_pause_when_inactive(&mut self, pause: bool, cx: &mut Context) { - if self.pause_when_inactive != pause { - self.pause_when_inactive = pause; - cx.notify(); - } - } - - /// Host visibility gate — see [`Self::visible`]. - pub fn set_visible(&mut self, visible: bool, cx: &mut Context) { - if self.visible != visible { - self.visible = visible; - if !visible { - self.animator.stop(Instant::now()); - self.animator.cancel_transition(); - } - self.geometry_dirty = true; - cx.notify(); - } - } - - pub fn state_value(&self) -> OrbState { - self.animator.state() - } - - pub fn size_value(&self) -> OrbSize { - self.animator.size() - } - - pub fn theme_value(&self) -> OrbTheme { - self.theme - } - - pub fn speed_value(&self) -> f32 { - self.animator.speed() - } - - pub fn is_paused(&self) -> bool { - self.paused - } - - pub fn reduced_motion_value(&self) -> bool { - self.reduced_motion - } - - pub fn target_fps_value(&self) -> f32 { - self.target_fps - } - - pub fn pause_when_inactive_value(&self) -> bool { - self.pause_when_inactive - } - - pub fn is_visible(&self) -> bool { - self.visible - } - - /// Ink direction, resolved against the installed bezel appearance — - /// `Auto` follows it, no window subscription needed (the appearance is - /// process-wide and its switch repaints the window anyway). - fn dark(&self, cx: &gpui::App) -> bool { - match self.theme { - OrbTheme::Dark => true, - OrbTheme::Light => false, - OrbTheme::Auto => crate::theme::Theme::of(cx).appearance.is_dark(), - } - } - - /// Queue the next redraw, honouring [`Self::target_fps`]. - fn schedule_tick(&mut self, cx: &mut Context) { - // Parent renders may happen between animation frames. Keep the timer - // already in flight rather than cancel/reallocate it and push the next - // frame farther into the future. - if self.tick.is_some() { - return; - } - let period = Duration::from_secs_f32(1.0 / self.target_fps); - self.tick = Some(cx.spawn(async move |this, cx| { - cx.background_executor().timer(period).await; - let _ = this.update(cx, |orb, cx| { - orb.tick = None; - orb.geometry_dirty = true; - cx.notify(); - }); - })); - } -} - -impl Render for Orb { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // An orb in a background window stops ticking entirely, so it needs a - // nudge to start again when the window comes back. Registered once, - // here, because it needs a `Window`. - if self.activation.is_none() { - self.activation = Some(cx.observe_window_activation(window, |orb, window, cx| { - if orb.pause_when_inactive && !window.is_window_active() { - orb.animator.stop(Instant::now()); - } - orb.geometry_dirty = true; - cx.notify(); - })); - } - - let size_px = self.animator.pixels(); - let scale = self.scale; - let dark = self.dark(cx); - let reduced = self.reduced_motion || cx.reduce_motion(); - let animating = self.visible - && !self.paused - && !reduced - && (!self.pause_when_inactive || window.is_window_active()); - self.animator.tick(Instant::now(), animating, reduced); - let r_min = self.animator.r_min(); - - // Only ticks and semantic changes invalidate geometry. A parent can - // re-render much faster than this orb's target FPS; those extra renders - // reuse the retained frame rather than running animation math again. - if self.geometry_dirty { - self.animator.draw(&mut self.frame.borrow_mut()); - self.geometry_dirty = false; - } - - if animating { - self.schedule_tick(cx); - } else { - // Drop any in-flight tick so a paused, hidden, or backgrounded orb - // costs nothing at all. - self.tick = None; - } - - let frame = self.frame.clone(); - div() - .size(px(size_px * scale)) - .flex_shrink_0() - .overflow_hidden() - .child( - canvas( - move |_bounds: Bounds, _window, _cx| (), - move |bounds, (), window, _cx| { - paint_frame(window, bounds, &frame.borrow(), dark, r_min, scale); - }, - ) - .size_full(), - ) - } -} - -/// Paint one frame of an orb at animation time `t` (seconds, unbounded) — the -/// pure, host-ticked form of [`Orb`]. Build it inside any render that runs on -/// a clock of its own; the reduced-motion convention is `t = 0.6`. -/// -/// `frame` is the caller's geometry buffer, overwritten here and handed to the -/// paint closure. Geometry is a function of `t`, so there is nothing to cache -/// between frames — but a host that keeps one buffer per orb reuses its two -/// `Vec`s forever instead of growing a pair from empty on every tick. -pub fn orb_element( - state: OrbState, - size: OrbSize, - t: f32, - frame: &Rc>, -) -> impl IntoElement { - let resolved = resolve_preset(state, size); - let size_px = size.pixels(); - let frame = frame.clone(); - draw_mode_into( - resolved.mode, - size_px, - t, - &resolved.opts, - &mut frame.borrow_mut(), - ); - let r_min = resolved.opts.r_min.unwrap_or(0.3); - div() - .size(px(size_px)) - .flex_shrink_0() - .overflow_hidden() - .child( - canvas( - move |_bounds: Bounds, _window, _cx| (), - move |bounds, (), window, cx| { - let dark = crate::theme::Theme::of(cx).appearance.is_dark(); - paint_frame(window, bounds, &frame.borrow(), dark, r_min, 1.0); - }, - ) - .size_full(), - ) -} - -fn sanitize_scale(scale: f32) -> f32 { - if scale.is_finite() { - scale.clamp(0.25, 8.0) - } else { - 1.0 - } -} - -fn sanitize_fps(fps: f32) -> f32 { - if fps.is_finite() { - fps.clamp(1.0, 30.0) - } else { - DEFAULT_TARGET_FPS - } -} diff --git a/crates/ui/src/orb/paint.rs b/crates/ui/src/orb/paint.rs deleted file mode 100644 index 01a7487449..0000000000 --- a/crates/ui/src/orb/paint.rs +++ /dev/null @@ -1,133 +0,0 @@ -// Extracted from Bezel 6141af9c16f7353cdf36003f7404e0a94566a163; MIT. See THIRD_PARTY_NOTICES.md. -//! Paint a backend-agnostic [`Frame`] into a GPUI window. - -use gpui::{ - BorderStyle, Bounds, PathBuilder, Pixels, Point, Window, opaque_grey, point, px, quad, size, - transparent_black, -}; -use zeron_orb::engine::{Frame, Line}; - -/// Map ink `white` + substrate into a grayscale with alpha. -fn ink_color(white: f32, alpha: f32, dark: bool) -> gpui::Hsla { - let w = white.clamp(0.0, 1.0); - let lightness = if dark { 1.0 - w } else { w }; - opaque_grey(lightness, alpha.clamp(0.0, 1.0)) -} - -/// Paint every stroke as its own `Path`, at its exact colour. -/// -/// Merging segments into shared `PathBuilder`s was tried and reverted. It is -/// tempting — a single builder accepts any number of disjoint subpaths, and -/// each `Path` primitive costs GPUI a full two-pass render — but it visibly -/// brightens the connecting state's web. -/// -/// The cause is that GPUI composites paths in two stages with different blend -/// modes: subpaths of one path are rasterised together into a cleared -/// intermediate texture with premultiplied-alpha blending, and that texture is -/// then blitted with `color: OVER, alpha: ADDITIVE`. So where strokes overlap, -/// merging changes which stage accumulates them, and the result does not match -/// painting each segment separately. Merging also forces a shared colour per -/// path, which costs another approximation on top. -/// -/// Measured: batching saves nothing at one orb (2.0 % either way) and about one -/// point of a core at twelve. Not worth a visible change to the artwork. -fn paint_lines(window: &mut Window, origin: Point, lines: &[Line], dark: bool, scale: f32) { - for l in lines { - if l.a < 0.02 { - continue; - } - let mut b = PathBuilder::stroke(px(l.w * scale)); - b.move_to(point( - origin.x + px(l.x1 * scale), - origin.y + px(l.y1 * scale), - )); - b.line_to(point( - origin.x + px(l.x2 * scale), - origin.y + px(l.y2 * scale), - )); - if let Ok(path) = b.build() { - window.paint_path(path, ink_color(l.white, l.a, dark)); - } - } -} - -/// Paint dots as rounded quads (true circles at GPU level via corner radii). -fn paint_dots( - window: &mut Window, - origin: Point, - frame: &Frame, - dark: bool, - r_min: f32, - scale: f32, -) { - for d in &frame.dots { - if d.a < 0.02 { - continue; - } - let r = d.r.max(r_min) * scale; - let diameter = r * 2.0; - let bounds = Bounds { - origin: point( - origin.x + px(d.x * scale - r), - origin.y + px(d.y * scale - r), - ), - size: size(px(diameter), px(diameter)), - }; - // Fully rounded corners → disk. - window.paint_quad(quad( - bounds, - px(r), - ink_color(d.white, d.a, dark), - px(0.), - transparent_black(), - BorderStyle::default(), - )); - } -} - -/// Verification scaffold: `ORB_NO_LAYER=1` paints without the `paint_layer` -/// wrap, i.e. the pre-optimization path. `scripts/golden-diff.sh` uses it to -/// render both paths from one binary and diff the results pixel-for-pixel. -/// Read once; costs a single relaxed atomic load per frame. -fn no_layer() -> bool { - static N: std::sync::OnceLock = std::sync::OnceLock::new(); - *N.get_or_init(|| std::env::var("ORB_NO_LAYER").as_deref() == Ok("1")) -} - -/// Paint a complete frame into `bounds` (top-left of the orb). -/// -/// Background is fully transparent — the host supplies the substrate. -/// `scale` magnifies the preset's geometry uniformly (dots, strokes and -/// spacing), so a preset tuned at one edge length stays crisp at another. -pub fn paint_frame( - window: &mut Window, - bounds: Bounds, - frame: &Frame, - dark: bool, - r_min: f32, - scale: f32, -) { - if no_layer() { - paint_lines(window, bounds.origin, &frame.lines, dark, scale); - paint_dots(window, bounds.origin, frame, dark, r_min, scale); - return; - } - - // Everything is painted inside one layer, which is the single biggest win - // in the paint path. Outside a layer, `Scene::insert_primitive` calls - // `BoundsTree::insert` for *every* primitive to compute its draw order — - // a bounding-volume-hierarchy insertion whose `find_max_ordering` walk - // degenerates when primitives overlap heavily, which is exactly the case - // here (hundreds of discs stacked inside a 64px box). Inside a layer the - // tree is touched once and every primitive inherits the layer's order. - // - // Ordering is preserved: `Scene::finish` sorts with `sort_by_key`, which is - // stable, so quads sharing the layer's order keep their insertion order — - // and the engine already emits dots back-to-front. The painter's algorithm - // still holds. Confirmed by pixel-diffing the golden grid against a build - // with this call removed: every dot-only orb came out byte-identical. - window.paint_layer(bounds, |window| { - paint_lines(window, bounds.origin, &frame.lines, dark, scale); - paint_dots(window, bounds.origin, frame, dark, r_min, scale); - }); -} diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs index b40546df36..d302f1c1c3 100644 --- a/crates/ui/src/shell.rs +++ b/crates/ui/src/shell.rs @@ -83,17 +83,16 @@ use panel_layout::{AuxiliaryPanel, FitInputs, HorizontalPanelFit, panel_content_ mod panel_interaction_tests; pub(crate) mod project_icon; mod pull_request_pane; +mod search_warm; mod session_info; mod side_chats; mod sidebar_pins; mod sidebar_sections; -mod search_warm; pub(crate) mod spaces; mod thread_search; use side_chats::SideChatTab; mod tabs; mod voice_stage; -use voice_stage::VOICE_STAGE_ORB_SCALE; use spaces::{AddSpaceFlow, RenameSpaceDialog}; @@ -1941,11 +1940,21 @@ pub struct Shell { composer: Entity, /// The window's voice orchestrator: independent of the selected chat. voice: Entity, - /// Sidebar footer orb (session live) and the full-window stage's orb. - voice_footer_orb: Entity, - voice_stage_orb: Entity, - /// Above the composer, only while the orchestrator's own chat is open. - voice_composer_orb: Entity, + /// The call's glyph: one field, heard once a frame, drawn by the + /// footer, the composer, the stage and its call bar at their own sizes. + voice_field: crate::voice::glyph::Field, + /// The stage's own field, which dissolves into its artwork, and the + /// beam over the orchestrator's composer, heard with the field. + voice_stage_field: crate::voice::glyph::Field, + voice_beam: crate::voice::glyph::Beam, + /// The pointer on the stage's orb, from the orb's corner, and when it + /// last moved: the dots under it light and the light fades after. + voice_touch: Option<(f32, f32, std::time::Instant)>, + /// The stage transcript's scroll, and the newest turn and the length of + /// the words last shown: new words bring the newest into view, and a + /// reader scrolled back up is left reading until more arrive. + voice_transcript_scroll: gpui::ScrollHandle, + voice_transcript_seen: (String, usize), /// The stage caption's streaming veil (shared with mobile). voice_caption: zeron_veil::CaptionVeil, voice_stage_focus: FocusHandle, @@ -2356,26 +2365,16 @@ impl Shell { transcript.update(cx, |transcript, _| transcript.retain_for_route_exit()); let composer = cx.new(|cx| Composer::new(state.clone(), cx)); let voice = cx.new(|_| crate::voice::VoiceController::default()); - let voice_footer_orb = cx.new(|_| { - crate::orb::Orb::new() - .size(crate::orb::OrbSize::Inline) - .state_transition(Duration::from_millis(300)) - .visible(false) - }); - let voice_stage_orb = cx.new(|_| { - crate::orb::Orb::new() - .size(crate::orb::OrbSize::Hero) - .state_transition(Duration::from_millis(300)) - .scale(VOICE_STAGE_ORB_SCALE) - .visible(false) + #[cfg(debug_assertions)] + if std::env::var_os("ZERON_VOICE_PREVIEW").is_some() { + voice.update(cx, |voice, cx| voice.preview(cx)); + } + let voice_observation = cx.observe(&voice, |_: &mut Shell, voice, cx| { + // Whether the call has the microphone, for dictation to stand aside. + let listening = voice.read(cx).has_microphone(); + crate::voice::publish_listening(listening, cx); + cx.notify() }); - let voice_composer_orb = cx.new(|_| { - crate::orb::Orb::new() - .size(crate::orb::OrbSize::Avatar) - .state_transition(Duration::from_millis(300)) - .visible(false) - }); - let voice_observation = cx.observe(&voice, |_: &mut Shell, _, cx| cx.notify()); // Settings → Agents installed or toggled Codex: voice follows. let voice_catalog = cx.observe_global::(|this: &mut Shell, cx| { @@ -2508,9 +2507,23 @@ impl Shell { transcript, composer, voice, - voice_footer_orb, - voice_stage_orb, - voice_composer_orb, + voice_field: crate::voice::glyph::Field::new(crate::voice::glyph::Rings::Round), + voice_stage_field: crate::voice::glyph::Field::new(crate::voice::glyph::Rings::Round) + .soft_rimmed() + // A voice loses most of itself by the rim: the print grades + // from a hot heart to the small dots at the edge, as a + // halftone's does, rather than filling the disc evenly. + .falling(0.78) + // The desktop disc is darker than the phone's glass: its grid + // at rest is kept quiet, so the unlit rim is a texture in the + // material rather than a screen behind the light. + .resting(0.45), + // In its tray, on the queue's own surface: as present at rest as + // the dictation track, which it is the twin of. + voice_beam: crate::voice::glyph::Beam::default(), + voice_touch: None, + voice_transcript_scroll: gpui::ScrollHandle::new(), + voice_transcript_seen: (String::new(), 0), voice_caption: zeron_veil::CaptionVeil::new(voice_stage::STAGE_CAPTION_CHARS), voice_stage_focus: cx.focus_handle(), voice_stage_was_open: false, @@ -2787,6 +2800,7 @@ impl Shell { // Voice is an orchestrator over every thread: navigation keeps it, // only losing the engine that hosts it ends the session. if self.voice.read(cx).chat_id.is_some() + && !self.voice.read(cx).previewing() && state .read(cx) .engine() @@ -3846,7 +3860,8 @@ impl Shell { LinkAction::External }; } - let outcome = resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux", windows))); + let outcome = + resolved.web_outcome(cfg!(any(target_os = "macos", target_os = "linux", windows))); if outcome == LinkOutcome::Internal { self.set_surfaces_open(true, cx); self.add_browser_surface(activation.target.navigation.clone().ok(), window, cx); @@ -4499,7 +4514,8 @@ impl Shell { ) { let was_active = self.resolved_right_active(cx) == surface; let restore_focus = was_active && self.navigation_focus.in_right(window, cx); - self.navigation_focus.remember(&self.shortcut_focus, window, cx); + self.navigation_focus + .remember(&self.shortcut_focus, window, cx); let key = self.panel_key(cx); let files = match surface { RightSurface::File(id) => self.file_surfaces.get(&id).cloned(), @@ -5065,9 +5081,7 @@ impl Shell { } popover::HoverAction::Defer => { self.chat_copy_intent.defer(cx, move |this, cx| { - if this.chat_menu.is_open() - && this.chat_copy_intent.pending() == Some(&key) - { + if this.chat_menu.is_open() && this.chat_copy_intent.pending() == Some(&key) { this.chat_copy_intent.cancel(); this.set_chat_copy_open(key == CHAT_MENU_ROW_COPY, cx); this.chat_copy_intent.record_origin(pointer); @@ -5094,9 +5108,11 @@ impl Shell { this.chat_copy_intent.leave(&key); } })) - .on_mouse_move(cx.listener(move |this, event: &gpui::MouseMoveEvent, _, cx| { - this.hover_chat_menu_row(key, event.position, true, cx); - })) + .on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, _, cx| { + this.hover_chat_menu_row(key, event.position, true, cx); + }), + ) .child(row) .into_any_element() } @@ -5112,59 +5128,69 @@ impl Shell { ) -> AnyElement { let entity = cx.entity().downgrade(); let exit_entity = entity.clone(); - let mut row = popover::menu_row_nav(theme, open, false, format!("chat-menu-copy-{chat_id}")) - .id("chat-menu-copy") - .relative() - .on_click(cx.listener(|this, _, _, cx| { - this.chat_copy_intent.reset(); - this.set_chat_copy_open(true, cx); - cx.stop_propagation(); - })) - .child(icon(icons::COPY).size(px(16.0)).text_color(theme.text_muted)) - .child(div().flex_1().child(SharedString::from("Copy"))) - .child( - icon(icons::ALT_ARROW_RIGHT) - .size(px(14.0)) - .text_color(theme.text_muted), - ) - .child( - gpui::canvas( - move |bounds, window, cx| { - let left = bounds.right() + px(244.0) > window.viewport_size().width; - let _ = entity.update(cx, |this, cx| { - if this.chat_copy_left != left { - this.chat_copy_left = left; - cx.notify(); - } - }); - }, - move |trigger, _, window, _| { - if !open { - return; - } - // Leaving the trigger anywhere but toward the submenu - // closes it, as in the sidebar options menu. - window.on_mouse_event(move |event: &gpui::MouseMoveEvent, phase, _, cx| { - if phase != gpui::DispatchPhase::Bubble { - return; - } - let _ = exit_entity.update(cx, |this, cx| { - let (bounds, left) = (this.chat_copy_bounds, this.chat_copy_left); - if this - .chat_copy_intent - .contains_pointer(trigger, bounds, event.position, left) - { - return; + let mut row = + popover::menu_row_nav(theme, open, false, format!("chat-menu-copy-{chat_id}")) + .id("chat-menu-copy") + .relative() + .on_click(cx.listener(|this, _, _, cx| { + this.chat_copy_intent.reset(); + this.set_chat_copy_open(true, cx); + cx.stop_propagation(); + })) + .child( + icon(icons::COPY) + .size(px(16.0)) + .text_color(theme.text_muted), + ) + .child(div().flex_1().child(SharedString::from("Copy"))) + .child( + icon(icons::ALT_ARROW_RIGHT) + .size(px(14.0)) + .text_color(theme.text_muted), + ) + .child( + gpui::canvas( + move |bounds, window, cx| { + let left = bounds.right() + px(244.0) > window.viewport_size().width; + let _ = entity.update(cx, |this, cx| { + if this.chat_copy_left != left { + this.chat_copy_left = left; + cx.notify(); } - this.chat_copy_intent.reset(); - this.set_chat_copy_open(false, cx); }); - }); - }, - ) - .absolute() - .inset_0(), - ); + }, + move |trigger, _, window, _| { + if !open { + return; + } + // Leaving the trigger anywhere but toward the submenu + // closes it, as in the sidebar options menu. + window.on_mouse_event( + move |event: &gpui::MouseMoveEvent, phase, _, cx| { + if phase != gpui::DispatchPhase::Bubble { + return; + } + let _ = exit_entity.update(cx, |this, cx| { + let (bounds, left) = + (this.chat_copy_bounds, this.chat_copy_left); + if this.chat_copy_intent.contains_pointer( + trigger, + bounds, + event.position, + left, + ) { + return; + } + this.chat_copy_intent.reset(); + this.set_chat_copy_open(false, cx); + }); + }, + ); + }, + ) + .absolute() + .inset_0(), + ); if open { let submenu = self.render_chat_copy_submenu(chat_id, theme, cx); row = row.child(popover::nested_menu( @@ -5206,7 +5232,11 @@ impl Shell { popover::menu_row(theme, false, format!("{id}-{chat_id}")) .id(id) .debug_selector(move || id.to_owned()) - .child(icon(icons::COPY).size(px(16.0)).text_color(theme.text_muted)) + .child( + icon(icons::COPY) + .size(px(16.0)) + .text_color(theme.text_muted), + ) .child(label) }; let entity = cx.entity().downgrade(); @@ -5217,9 +5247,9 @@ impl Shell { .flex_col() .gap(px(2.0)) .when(has_path, |menu| { - menu.child(item("chat-copy-path", "Path".into()).on_click(cx.listener( - move |this, _, _, cx| this.copy_chat_path(&path_chat_id, cx), - ))) + menu.child(item("chat-copy-path", "Path".into()).on_click( + cx.listener(move |this, _, _, cx| this.copy_chat_path(&path_chat_id, cx)), + )) }) .child( item("chat-copy-zeron", "Zeron conversation link".into()).on_click(cx.listener( @@ -5227,9 +5257,11 @@ impl Shell { )), ) .when_some(harness_link, |menu, link| { - menu.child(item("chat-copy-harness", link.label.into()).on_click(cx.listener( - move |this, _, _, cx| this.copy_harness_conversation_link(&harness_id, cx), - ))) + menu.child( + item("chat-copy-harness", link.label.into()).on_click(cx.listener( + move |this, _, _, cx| this.copy_harness_conversation_link(&harness_id, cx), + )), + ) }) .when(session_id, |menu| { menu.child( @@ -5263,9 +5295,17 @@ impl Shell { }; if let Some(link) = link { cx.write_to_clipboard(ClipboardItem::new_string(link)); - self.show_notice(crate::toast::ToastKind::Success, "Zeron conversation link copied", cx); + self.show_notice( + crate::toast::ToastKind::Success, + "Zeron conversation link copied", + cx, + ); } else { - self.show_notice(crate::toast::ToastKind::Error, "Conversation link is not ready yet", cx); + self.show_notice( + crate::toast::ToastKind::Error, + "Conversation link is not ready yet", + cx, + ); } self.close_chat_menu(cx); cx.notify(); @@ -5281,7 +5321,11 @@ impl Shell { .and_then(crate::links::harness_conversation_link); if let Some(link) = link { cx.write_to_clipboard(ClipboardItem::new_string(link.url)); - self.show_notice(crate::toast::ToastKind::Success, format!("{} copied", link.label), cx); + self.show_notice( + crate::toast::ToastKind::Success, + format!("{} copied", link.label), + cx, + ); } self.close_chat_menu(cx); cx.notify(); @@ -5297,7 +5341,11 @@ impl Shell { .and_then(|chat| chat.harness_session_id.clone()); if let Some(id) = id.filter(|id| !id.trim().is_empty()) { cx.write_to_clipboard(ClipboardItem::new_string(id)); - self.show_notice(crate::toast::ToastKind::Success, "Harness session ID copied", cx); + self.show_notice( + crate::toast::ToastKind::Success, + "Harness session ID copied", + cx, + ); } self.close_chat_menu(cx); cx.notify(); @@ -5862,7 +5910,11 @@ impl Shell { if !revealed { // The sidebar explains its own refusals. if surface == ChatRenameSurface::Explorer { - self.show_notice(crate::toast::ToastKind::Error, "Open this side chat's parent to rename it", cx); + self.show_notice( + crate::toast::ToastKind::Error, + "Open this side chat's parent to rename it", + cx, + ); } cx.notify(); return; @@ -6488,7 +6540,11 @@ impl Shell { if local { shell.sync_flow = SyncFlow::Enabling; } - shell.show_notice(crate::toast::ToastKind::Error, format!("Could not cancel sign-in: {err}"), cx); + shell.show_notice( + crate::toast::ToastKind::Error, + format!("Could not cancel sign-in: {err}"), + cx, + ); } } cx.notify(); @@ -6817,7 +6873,11 @@ impl Shell { { shell.sync_flow = SyncFlow::Idle; } - shell.show_notice(crate::toast::ToastKind::Error, format!("Sign in failed: {err}"), cx); + shell.show_notice( + crate::toast::ToastKind::Error, + format!("Sign in failed: {err}"), + cx, + ); cx.notify(); } }) @@ -8873,13 +8933,19 @@ impl Shell { use zeron_proto::ConnectivityState as S; let conn = self.state.read(cx).connectivity.clone(); let selected = self.state.read(cx).selected_chat.as_deref(); - let chat = conn.chats.iter() + let chat = conn + .chats + .iter() .find(|c| Some(c.chat_id.as_str()) == selected); let chat_state = chat.map(|c| c.sync_state); let (label, glyph): (SharedString, AnyElement) = match conn.state { _ if chat_state == Some(zeron_proto::ChatSyncState::StorageError) => ( "Changes could not be saved".into(), - div().size(px(5.0)).rounded_full().bg(theme.warning).into_any_element(), + div() + .size(px(5.0)) + .rounded_full() + .bg(theme.warning) + .into_any_element(), ), S::Disabled => return None, S::Connected => { @@ -8887,9 +8953,13 @@ impl Shell { ( caption.into(), loaders::mini_mono_spinner( - "chat-sync-spinner", 2.0, theme.text_muted, - self.sidebar_pane.entity_id(), cx, - ).into_any_element(), + "chat-sync-spinner", + 2.0, + theme.text_muted, + self.sidebar_pane.entity_id(), + cx, + ) + .into_any_element(), ) } S::Offline => ( @@ -9260,52 +9330,56 @@ impl Shell { SIDEBAR_GLASS_FADE_BAND, true, true, - div().relative().flex_1().min_h_0().child(crate::overscroll::overscroll( - "sidebar-overscroll", - crate::overscroll::OverscrollSource::Handle(self.sidebar_scroll.clone()), - div() - .id("sidebar-lists") - .relative() - .size_full() - .overflow_y_scroll() - .track_scroll(&self.sidebar_scroll) - .on_drag_move::(cx.listener( - move |this, event: &gpui::DragMoveEvent, _, cx| { - if let Some(transfer) = this.sidebar_session_transfer.as_mut() { - transfer.viewport = Some(event.bounds); - } - if !event.bounds.contains(&event.event.position) { + div() + .relative() + .flex_1() + .min_h_0() + .child(crate::overscroll::overscroll( + "sidebar-overscroll", + crate::overscroll::OverscrollSource::Handle(self.sidebar_scroll.clone()), + div() + .id("sidebar-lists") + .relative() + .size_full() + .overflow_y_scroll() + .track_scroll(&self.sidebar_scroll) + .on_drag_move::(cx.listener( + move |this, event: &gpui::DragMoveEvent, _, cx| { if let Some(transfer) = this.sidebar_session_transfer.as_mut() { - transfer.preview = None; + transfer.viewport = Some(event.bounds); } - return; - } - let payload = event.drag(cx).clone(); - this.track_pinned_session_drag_pointer( - payload, - f32::from(event.event.position.y), - f32::from(event.bounds.top()), - f32::from(event.bounds.bottom()), - cx, - ); - }, - )) - // Empty space and Archived are not transfer targets. - .on_drop::(cx.listener( - |this, _: &SidebarSessionDrag, _, cx| { - this.cancel_sidebar_session_transfer(cx); - }, - )) - .px(px(Theme::SPACE_SM)) - .flex() - .flex_col() - // No "Sessions" header (user request) — the list - // is the whole column; a little air stands in. - .pt(px(SIDEBAR_LIST_PAD_TOP)) - .child(active_list) - .children(archived_section) - .children(moving_row), - )), + if !event.bounds.contains(&event.event.position) { + if let Some(transfer) = this.sidebar_session_transfer.as_mut() { + transfer.preview = None; + } + return; + } + let payload = event.drag(cx).clone(); + this.track_pinned_session_drag_pointer( + payload, + f32::from(event.event.position.y), + f32::from(event.bounds.top()), + f32::from(event.bounds.bottom()), + cx, + ); + }, + )) + // Empty space and Archived are not transfer targets. + .on_drop::(cx.listener( + |this, _: &SidebarSessionDrag, _, cx| { + this.cancel_sidebar_session_transfer(cx); + }, + )) + .px(px(Theme::SPACE_SM)) + .flex() + .flex_col() + // No "Sessions" header (user request) — the list + // is the whole column; a little air stands in. + .pt(px(SIDEBAR_LIST_PAD_TOP)) + .child(active_list) + .children(archived_section) + .children(moving_row), + )), ) .fade_overflow_y(&self.sidebar_scroll); @@ -10634,19 +10708,20 @@ impl Shell { let menu = if chat_id.is_empty() { menu } else { - let rename = popover::menu_row(&theme, false, format!("chat-menu-rename-{chat_id}")) - .id("chat-menu-rename") - .on_click(cx.listener(move |this, _, window, cx| { - // A side chat's title is edited on its row under the - // file tree: dock the explorer when the menu came - // from its tab. - if is_side_chat && !this.files_panel_open(cx) { - this.add_files_surface(window, cx); - } - this.open_rename_chat(rename_id.clone(), cx) - })) - .child(icon(icons::PEN).size(px(16.0)).text_color(theme.text_muted)) - .child(SharedString::from("Rename")); + let rename = + popover::menu_row(&theme, false, format!("chat-menu-rename-{chat_id}")) + .id("chat-menu-rename") + .on_click(cx.listener(move |this, _, window, cx| { + // A side chat's title is edited on its row under the + // file tree: dock the explorer when the menu came + // from its tab. + if is_side_chat && !this.files_panel_open(cx) { + this.add_files_surface(window, cx); + } + this.open_rename_chat(rename_id.clone(), cx) + })) + .child(icon(icons::PEN).size(px(16.0)).text_color(theme.text_muted)) + .child(SharedString::from("Rename")); let menu = menu.child(self.chat_menu_hover_row(CHAT_MENU_ROW_RENAME, rename, cx)); let menu = if is_side_chat { menu @@ -10675,20 +10750,21 @@ impl Shell { .child(self.chat_menu_hover_row(CHAT_MENU_ROW_ARCHIVE, archive, cx)) .child(self.chat_menu_hover_row(CHAT_MENU_ROW_COPY, copy, cx)) }; - let delete = popover::menu_row(&theme, false, format!("chat-menu-delete-{chat_id}")) - .id("chat-menu-delete") - .text_color(theme.danger) - .on_click(cx.listener(move |this, _, _, cx| { - this.close_chat_menu(cx); - this.delete_confirm = Some(delete_id.clone()); - cx.notify(); - })) - .child( - icon(icons::TRASH_BIN_MINIMALISTIC) - .size(px(16.0)) - .text_color(theme.danger), - ) - .child(SharedString::from("Delete…")); + let delete = + popover::menu_row(&theme, false, format!("chat-menu-delete-{chat_id}")) + .id("chat-menu-delete") + .text_color(theme.danger) + .on_click(cx.listener(move |this, _, _, cx| { + this.close_chat_menu(cx); + this.delete_confirm = Some(delete_id.clone()); + cx.notify(); + })) + .child( + icon(icons::TRASH_BIN_MINIMALISTIC) + .size(px(16.0)) + .text_color(theme.danger), + ) + .child(SharedString::from("Delete…")); menu.child(popover::menu_separator()) .child(self.chat_menu_hover_row(CHAT_MENU_ROW_DELETE, delete, cx)) }; @@ -10709,25 +10785,22 @@ impl Shell { let enabled = !self.tabs_to_close(surface, action, cx).is_empty(); let key = key.clone(); let row = popover::menu_row(&theme, false, label) - .id(SharedString::from(label)) - .when(!enabled, |el| el.opacity(0.4).cursor_default()) - .on_click(cx.listener(move |this, _, window, cx| { - if !enabled { - return; - } - this.close_chat_menu(cx); - if this.panel_key(cx) == key { - for tab in this.tabs_to_close(surface, action, cx) { - this.close_right_surface(tab, window, cx); - } + .id(SharedString::from(label)) + .when(!enabled, |el| el.opacity(0.4).cursor_default()) + .on_click(cx.listener(move |this, _, window, cx| { + if !enabled { + return; + } + this.close_chat_menu(cx); + if this.panel_key(cx) == key { + for tab in this.tabs_to_close(surface, action, cx) { + this.close_right_surface(tab, window, cx); } - })) - .child(label); - menu = menu.child(self.chat_menu_hover_row( - CHAT_MENU_ROW_TAB + ix as u8, - row, - cx, - )); + } + })) + .child(label); + menu = + menu.child(self.chat_menu_hover_row(CHAT_MENU_ROW_TAB + ix as u8, row, cx)); } } let menu = menu.into_any_element(); @@ -11054,8 +11127,9 @@ impl Shell { // over a column too tight for that, it floats once opened. let (session_docked, session_reveal, session_reserve) = self.session_info_frame(has_selection, cx); - self.transcript - .update(cx, |transcript, cx| transcript.set_right_reserve(session_reserve, cx)); + self.transcript.update(cx, |transcript, cx| { + transcript.set_right_reserve(session_reserve, cx) + }); // Panel open/close glides the composer width; a window resize or a // seam drag is direct manipulation, so the width tracks the pointer. let direct_width = @@ -11219,10 +11293,7 @@ impl Shell { let session_card = self.render_session_info( session_docked, session_reveal, - self.viewport_height - - Theme::TITLEBAR_HEIGHT - - self.bottom_stack.get() - - 24.0, + self.viewport_height - Theme::TITLEBAR_HEIGHT - self.bottom_stack.get() - 24.0, cx, ); self.chat_dropzone("chat-dropzone", self.composer.clone(), cx) @@ -11331,6 +11402,10 @@ impl Shell { let measured_has_composer = self.bottom_stack_has_composer.clone(); let contains_composer = (has_spaces || no_project || has_appshots) && has_selection; let composer = self.composer.clone(); + // The call's tray belongs to the composer's block, as the + // queue does: what floats above the composer (the jump pill) + // then clears it too. + let voice_tray = self.render_voice_composer_orb(composer_width, cx); div() .flex_none() .relative() @@ -11355,7 +11430,6 @@ impl Shell { .absolute() .inset_0(), ) - .children(self.render_voice_composer_orb(composer_width, cx)) .child(status) .when(has_spaces || no_project || has_appshots, |el| { let composer_opacity = self.composer_dock.borrow().opacity(); @@ -11367,6 +11441,7 @@ impl Shell { .w(px(composer_width)) .opacity(composer_opacity) .mx_auto() + .children(voice_tray) .child(self.composer.clone()) .children(if has_selection { self.render_jump_to_bottom(cx) @@ -13901,15 +13976,9 @@ impl Render for Shell { // (zero layout width, same idiom as the changes-pane grabber) // so the sidebar's right gutter stays exactly as wide as its // left one — a 5px flex child here read as lopsided spacing. - let sidebar_seam = div() - .w(px(0.0)) - .h_full() - .flex_none() - .relative() - .children( - sidebar_handle - .map(|handle| handle.left(px(-PANE_RESIZE_HITBOX_HALF_WIDTH))), - ); + let sidebar_seam = div().w(px(0.0)).h_full().flex_none().relative().children( + sidebar_handle.map(|handle| handle.left(px(-PANE_RESIZE_HITBOX_HALF_WIDTH))), + ); // Keep the right resize target outside the pane's // overflow-hidden width container. This mirrors the sidebar // seam and lets the target straddle both adjacent panes. @@ -14169,17 +14238,26 @@ mod tests { chat.sync_state = S::Waiting; chat.connected = false; - assert_eq!(chat_sync_pill_caption(&chat), Some("Sync queued — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Sync queued — changes are saved") + ); chat.sync_state = S::Connecting; assert_eq!(chat_sync_pill_caption(&chat), Some("Syncing…")); chat.sync_state = S::Offline; - assert_eq!(chat_sync_pill_caption(&chat), Some("Offline — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Offline — changes are saved") + ); // Real pending pushes remain visible even with a live room. chat.connected = true; chat.pending_pushes = 1; chat.sync_state = S::Waiting; - assert_eq!(chat_sync_pill_caption(&chat), Some("Sync queued — changes are saved")); + assert_eq!( + chat_sync_pill_caption(&chat), + Some("Sync queued — changes are saved") + ); chat.sync_state = S::Connecting; assert_eq!(chat_sync_pill_caption(&chat), Some("Syncing…")); } @@ -15857,7 +15935,8 @@ mod exit_regressions { settings::update(settings::SavePolicy::Immediate, cx, |settings| { settings.wallpaper_folder = Some(dir.path().join("wallpapers")); settings.wallpaper_source = Some(dir.path().join("wallpapers/current.png")); - settings.wallpaper_history = vec![dir.path().join("wallpapers/current.png")]; + settings.wallpaper_history = + vec![dir.path().join("wallpapers/current.png")]; settings.window_geometry = geometry; settings.open_web_links_in_zeron = open_links_in_zeron; settings.pull_request_destination = @@ -15883,8 +15962,14 @@ mod exit_regressions { shell.settings.terminal_height = 300.0 + step as f32; shell.schedule_save(cx); let current = settings::current(cx); - assert_eq!(current.wallpaper_history, vec![dir.path().join("wallpapers/current.png")]); - assert_eq!(current.wallpaper_folder, Some(dir.path().join("wallpapers"))); + assert_eq!( + current.wallpaper_history, + vec![dir.path().join("wallpapers/current.png")] + ); + assert_eq!( + current.wallpaper_folder, + Some(dir.path().join("wallpapers")) + ); assert_eq!( current.wallpaper_source, Some(dir.path().join("wallpapers/current.png")) @@ -15932,7 +16017,10 @@ mod exit_regressions { Some("remote-pr-device") ); assert_eq!(loaded.window_geometry, geometry); - assert_eq!(loaded.wallpaper_history, vec![dir.path().join("wallpapers/current.png")]); + assert_eq!( + loaded.wallpaper_history, + vec![dir.path().join("wallpapers/current.png")] + ); assert_eq!(loaded.wallpaper_folder, Some(dir.path().join("wallpapers"))); assert_eq!( loaded.wallpaper_source, @@ -17212,7 +17300,10 @@ mod exit_regressions { }); shell.settings.space_filter = None; shell.open_chat("elsewhere".into(), cx); - assert_eq!(shell.state.read(cx).selected_space.as_deref(), Some("other")); + assert_eq!( + shell.state.read(cx).selected_space.as_deref(), + Some("other") + ); shell.open_new_session(None, cx); let state = shell.state.read(cx); assert!(state.selected_chat.is_none()); @@ -18895,9 +18986,7 @@ mod settings_modal_regressions { /// Carried out of the sidebar it follows the pointer, and dropped on a /// conversation it becomes a chat chip in that composer. #[gpui::test] - fn a_dragged_session_leaves_the_sidebar_and_drops_as_a_chat_mention( - cx: &mut TestAppContext, - ) { + fn a_dragged_session_leaves_the_sidebar_and_drops_as_a_chat_mention(cx: &mut TestAppContext) { let dir = tempfile::tempdir().unwrap(); init_settings_test(settings::UiSettings::default(), dir.path(), cx); let (shell, cx) = cx.add_window_view(|_, cx| test_shell(dir.path(), cx)); @@ -18945,10 +19034,16 @@ mod settings_modal_regressions { // selector; the lifted row's own window position is the check. let sidebar_width = shell.read_with(cx, |shell, _| shell.settings.sidebar_width); shell.read_with(cx, |shell, _| { - let transfer = shell.sidebar_session_transfer.as_ref().expect("still dragging"); + let transfer = shell + .sidebar_session_transfer + .as_ref() + .expect("still dragging"); let left = transfer.slide_x.to; let expected = f32::from(transfer.pointer.x - transfer.cursor_offset.x); - assert!(left > sidebar_width, "outside the sidebar the row follows the pointer"); + assert!( + left > sidebar_width, + "outside the sidebar the row follows the pointer" + ); assert!((left - expected).abs() < 0.5); }); let drop = gpui::point(from.x + px(724.0), from.y + px(40.0)); diff --git a/crates/ui/src/shell/voice_stage.rs b/crates/ui/src/shell/voice_stage.rs index 5e1ed96de9..6d26be82c3 100644 --- a/crates/ui/src/shell/voice_stage.rs +++ b/crates/ui/src/shell/voice_stage.rs @@ -4,20 +4,42 @@ //! threads freely while the orchestrator keeps listening. use super::*; -use zeron_proto::voice::{VoicePhase, VoiceWork}; +use crate::voice::glyph::{self, Grid, Outline}; +use zeron_proto::voice::{VoicePhase, VoiceRole, VoiceWork}; -/// The stage paints the hero preset magnified to fill the canvas. -pub(super) const VOICE_STAGE_ORB_SCALE: f32 = 2.25; -/// Stage orb footprint, including breathing room around the artwork. -const STAGE_ORB_BOX: f32 = 128.0 * VOICE_STAGE_ORB_SCALE + 112.0; -const STAGE_ENTER_MS: f32 = 460.0; -const STAGE_EXIT_MS: f32 = 220.0; -/// Captions show the tail of a long utterance rather than wrapping off-stage. -pub(super) const STAGE_CAPTION_CHARS: usize = 160; +/// Small, the glyph is the call's beam in miniature: a strip the width of +/// the icons beside it, its dots fine enough for a voice to read as a wave +/// along it, as the dictation track reads. Its dots, width and height. +const FOOTER_BEAM: (f32, f32, f32) = (1.4, 22.0, 11.0); +/// The stage's field: dots across and their size. It fills its disc, its +/// rim easing into the material, and a voice pushes out from its heart. Its +/// dots are the dictation beam's, about 0.02 in across as the phone's are +/// (`VoiceStageViewController`): one resolution for every voice glyph. +const STAGE_FIELD: (usize, f32) = (44, 3.0); +/// How far above the middle the orb settles, leaving the words room below. +const STAGE_ORB_LIFT: f32 = 120.0; +/// The transcript's column. +const STAGE_TRANSCRIPT_WIDTH: f32 = 560.0; +/// The veil behind the orb: how much of the page's colour it lays over the +/// picture, then how far it holds past the orb and how long its blur runs, +/// as shares of the orb's width. +const STAGE_ORB_VEIL: (f32, f32, f32) = (0.97, 0.5, 0.75); +/// How far down the stage the hero artwork reaches, as a share of its +/// height, and at most: it stays behind the orb, clear of the words. +const STAGE_HERO: (f32, f32) = (0.58, 620.0); +/// The tray over the orchestrator's composer: the height its beam shows +/// above the composer, and how far it dissolves into the tray's controls. +const COMPOSER_BEAM: (f32, f32) = (22.0, 20.0); +/// The tray's mute and hang-up. +const TRAY_CONTROL: f32 = 24.0; +const STAGE_ENTER_MS: f32 = 620.0; +const STAGE_EXIT_MS: f32 = 300.0; +/// The newest turn veils in whole, as the phone's does. +pub(super) const STAGE_CAPTION_CHARS: usize = 4000; const FOOTER_HOVER: &str = "voice-footer-orb"; /// Call-bar geometry: round controls inside a floating pill. -const BAR_CONTROL: f32 = 44.0; -const BAR_PAD: f32 = 8.0; +const BAR_CONTROL: f32 = 36.0; +const BAR_PAD: f32 = 6.0; /// Stage visibility for a transition that flipped `elapsed_ms` ago. pub(super) fn stage_reveal(open: bool, elapsed_ms: f32, reduced: bool) -> f32 { @@ -30,6 +52,58 @@ pub(super) fn stage_reveal(open: bool, elapsed_ms: f32, reduced: bool) -> f32 { } impl Shell { + /// Hears the call into its glyph, once a frame however many places draw + /// it, and keeps frames coming while it moves. Under reduced motion the + /// glyph holds still. + fn voice_glyph(&mut self, cx: &mut Context) -> (glyph::Mode, glyph::Inks) { + let voice = self.voice.read(cx); + let (heard, mode, live) = (voice.heard(), voice.glyph_mode(), voice.is_live()); + if live && !self.reduced_motion { + let now = std::time::Instant::now(); + self.voice_field.hear(heard, now); + self.voice_stage_field.hear(heard, now); + self.voice_beam.hear(heard, now); + // A change of state passes from one pattern to the next. + self.voice_field.shift(mode); + self.voice_stage_field.shift(mode); + self.voice_beam.shift(mode); + if self.voice_field.moving(mode) { + self.motion_active.set(true); + } + } + (mode, glyph::inks(Theme::of(cx))) + } + + /// The call's beam in miniature: the same beam the tray and the track + /// show, in a pill the size of an icon. + fn voice_mini(&self, mode: glyph::Mode, inks: glyph::Inks) -> AnyElement { + let (dot, width, height) = FOOTER_BEAM; + glyph::beam( + &self.voice_beam, + Grid::dense(dot), + mode, + inks, + Outline::Stadium, + ) + .w(px(width)) + .h(px(height)) + .flex_none() + .into_any_element() + } + + /// The pointer's touch on the stage's orb, from the orb's corner, as it + /// stands now: its light holds, then fades, after the pointer moves on. + /// Frames keep coming while it fades. + fn voice_orb_touch(&self) -> Option { + let (x, y, at) = self.voice_touch?; + let strength = glyph::Touch::strength_after(at.elapsed().as_secs_f32()); + if strength <= 0.0 { + return None; + } + self.motion_active.set(true); + Some(glyph::Touch { x, y, strength }) + } + /// The footer microphone: a new orchestrator in a projectless Codex chat /// on this device. The chat's model follows the composer when it already /// targets Codex; otherwise Codex's own default model runs the delegations. @@ -137,18 +211,7 @@ impl Shell { let failure = (voice.phase == VoicePhase::Failed) .then(|| voice.reason_text()) .filter(|text| !text.is_empty()); - let (orb_state, microphone, speaker) = ( - voice.orb_state(), - voice.microphone_level(), - voice.speaker_level(), - ); - let reduced = self.reduced_motion; - self.voice_footer_orb.update(cx, |orb, cx| { - orb.set_visible(live, cx); - orb.set_state(orb_state, cx); - orb.set_audio_levels(microphone, speaker, cx); - orb.set_reduced_motion(reduced, cx); - }); + let (mode, inks) = self.voice_glyph(cx); let button = div() .id("voice-trigger") @@ -186,7 +249,7 @@ impl Shell { .on_click( cx.listener(move |this, _, _, cx| this.set_voice_stage_open(!stage_open, cx)), ) - .child(self.voice_footer_orb.clone()) + .child(self.voice_mini(mode, inks)) .into_any_element() .into(); } @@ -202,7 +265,7 @@ impl Shell { }) .on_click(cx.listener(|this, _, _, cx| this.start_voice(cx))) .child( - icon(icons::MICROPHONE) + icon(icons::VOICE_WAVE) .size(px(15.0)) .text_color(motion::hover_blend( FOOTER_HOVER, @@ -272,58 +335,122 @@ impl Shell { .into() } - /// The live orb above the orchestrator chat's composer. Every other chat - /// keeps its plain composer; pressing the orb returns to the stage. + /// The live call above the composer of every chat while it runs, so the + /// call is never out of reach; pressing the orb returns to the stage. pub(super) fn render_voice_composer_orb( &mut self, composer_width: f32, cx: &mut Context, ) -> Option { let voice = self.voice.read(cx); + // Every open conversation, never the new-thread canvas: its + // selectors sit where the strip would. let shown = matches!(self.route, Route::Chat) && voice.is_live() - && voice.chat_id.is_some() - && voice.chat_id == self.state.read(cx).selected_chat; - let (orb_state, microphone, speaker) = ( - voice.orb_state(), - voice.microphone_level(), - voice.speaker_level(), - ); - let reduced = self.reduced_motion; - self.voice_composer_orb.update(cx, |orb, cx| { - orb.set_visible(shown, cx); - orb.set_state(orb_state, cx); - orb.set_audio_levels(microphone, speaker, cx); - orb.set_reduced_motion(reduced, cx); - }); + && self.state.read(cx).selected_chat.is_some(); if !shown { return None; } - let orb = div() + // A tray growing out of the composer, as the queue's does: tucked + // under its top edge and as wide as its straight top, ending where + // its corners begin to round. The call's beam rises in it; pressing + // the beam returns to the stage. Mute and hang-up sit at its end. + let (mode, inks) = self.voice_glyph(cx); + let (height, fade) = COMPOSER_BEAM; + let theme = Theme::of(cx).clone(); + let muted = self.voice.read(cx).muted(); + let overlap = crate::composer::QUEUE_COMPOSER_OVERLAP; + let pill = composer_width - 2.0 * Theme::SPACE_LG; + let width = (pill - 2.0 * crate::composer::COMPOSER_RADIUS).max(0.0); + let small = |id: &'static str, active: bool| { + round_control(id, &theme, active).size(px(TRAY_CONTROL)) + }; + let mic = small("voice-tray-mic", muted) + .aria_label(if muted { + "Unmute microphone" + } else { + "Mute microphone" + }) + .tooltip(stage_tooltip(if muted { "Unmute" } else { "Mute" })) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.voice.update(cx, |voice, cx| voice.toggle_mute(cx)) + })) + .child( + icon(if muted { + icons::MICROPHONE_OFF + } else { + icons::MICROPHONE + }) + .size(px(13.0)) + .text_color(if muted { theme.on_solid } else { theme.text }), + ); + let end = small("voice-tray-end", false) + .aria_label("End voice") + .tooltip(stage_tooltip("End voice")) + .bg(motion::hover_blend( + "voice-tray-end", + theme.danger, + theme.danger.blend(gpui::black().opacity(0.14)), + )) + .on_click(cx.listener(|this, _, _, cx| { + cx.stop_propagation(); + this.end_voice(cx) + })) + .child( + icon(icons::PHONE_HANG_UP) + .size(px(13.0)) + .text_color(gpui::white()), + ); + let beam = div() .id("voice-composer-orb") .debug_selector(|| "voice-composer-orb".into()) .role(gpui::Role::Button) .aria_label("Open voice") .tab_index(0) - .rounded_full() + .flex_1() + .min_w_0() + .h_full() .cursor_pointer() .tooltip(stage_tooltip("Open voice")) .on_click(cx.listener(|this, _, _, cx| this.set_voice_stage_open(true, cx))) - .child(self.voice_composer_orb.clone()); - Some( - motion::fade_in( - "voice-composer-orb-in", + // From the tray's padding, as its controls sit at the other end; + // it only dissolves where it meets them. + .child( + crate::edge_fade::edge_faded( + fade, + false, + false, + glyph::beam( + &self.voice_beam, + Grid::dense(glyph::BEAM_DOT), + mode, + inks, + // No frame to follow: the beam's own dots thin out + // at its ends, as the orb's do at its rim. + glyph::Outline::Square, + ) + .size_full(), + ) + .fade_right(true), + ); + // The queue's own surface, tucked under the composer's top edge: the + // call sits in the conversation as a queued message does. + let tray = crate::queue::queue_panel_surface(&theme, false) + .w(px(width)) + .mx_auto() + .mb(px(-overlap)) + .child( div() - .w_full() - .max_w(px(composer_width)) - .mx_auto() - .pb(px(4.0)) + .h(px(height)) .flex() - .justify_center() - .child(orb), - ) - .into_any_element(), - ) + .items_center() + .gap(px(4.0)) + .child(beam) + .child(mic) + .child(end), + ); + Some(motion::fade_quick("voice-composer-orb-in", tray).into_any_element()) } /// How far the stage has arrived: 0 hidden, 1 fully covering the page. @@ -352,53 +479,52 @@ impl Shell { window: &mut Window, cx: &mut Context, ) -> Option { - let open = self.voice.read(cx).stage_open; let reveal = self.voice_stage_reveal(cx); if reveal > 0.0 && reveal < 1.0 { self.motion_active.set(true); } + let (mode, inks) = self.voice_glyph(cx); let voice = self.voice.read(cx); - let (orb_state, microphone, speaker) = ( - voice.orb_state(), - voice.microphone_level(), - voice.speaker_level(), - ); - let (caption_item, partial) = ( - voice.caption_item().map(str::to_owned), - voice.caption.text().to_owned(), - ); + // The call's recent turns, oldest first, each with its speaker once + // known: the transcript reads as the conversation does. + let lines: Vec<(Option, String, Option)> = voice + .caption + .lines() + .filter(|line| !line.text.trim().is_empty()) + .map(|line| { + ( + line.item.map(str::to_owned), + line.text.trim().to_owned(), + line.speaker, + ) + }) + .collect(); let snapshot = voice.snapshot.clone(); let chat_id = voice.chat_id.clone(); let awaiting = snapshot .as_ref() .is_some_and(|s| s.work == VoiceWork::AwaitingInput); - let reduced = self.reduced_motion; - self.voice_stage_orb.update(cx, |orb, cx| { - orb.set_visible(open, cx); - orb.set_state(orb_state, cx); - orb.set_audio_levels(microphone, speaker, cx); - orb.set_reduced_motion(reduced, cx); - // The orb blooms out of the footer as the stage arrives. - orb.set_scale(VOICE_STAGE_ORB_SCALE * (0.82 + 0.18 * reveal), cx); - }); if reveal <= 0.001 { return None; } - // Streamed words veil in like transcript text; a new speaker turn - // fades the previous caption out (zeron-veil, shared with mobile). - let caption = self.voice_caption.advance( - caption_item.as_deref(), - &partial, - std::time::Instant::now(), - self.reduced_motion, - ); + // The newest turn's words veil in like transcript text (zeron-veil, + // shared with mobile); every turn keeps one size throughout. + let newest = lines.last().map(|(item, text, _)| { + self.voice_caption.advance( + item.as_deref(), + text, + std::time::Instant::now(), + self.reduced_motion, + ) + }); if self.voice_caption.is_animating() { self.motion_active.set(true); } let theme = Theme::of(cx).clone(); let viewport = window.viewport_size(); + let height = f32::from(viewport.height); // The sidebar stays usable beside the stage; collapsed, the stage // spans the whole window. Settings always shows its own sidebar. let left = if matches!(self.route, Route::Settings(_)) { @@ -407,6 +533,24 @@ impl Shell { self.sidebar_now() }; let width = (f32::from(viewport.width) - left).max(0.0); + + // The orb arrives once, settling above the middle with the words' + // room below it: it grows the last bit of the way as the stage + // comes, and never moves after. + let (dots, dot) = STAGE_FIELD; + let grid = Grid::dense(dot * (0.84 + 0.16 * reveal)); + let (room, _) = grid.extent(dots, dots); + let orb_top = (height / 2.0 - STAGE_ORB_LIFT - room / 2.0) + .max(Theme::TITLEBAR_HEIGHT + 20.0) + + 28.0 * (1.0 - reveal); + let orb_left = (width - room) / 2.0; + let bar_top = 28.0 + BAR_CONTROL + 2.0 * BAR_PAD; + // The words live in the band between the orb and the call bar, + // centred in it while they are few and filling it once they are + // many, so the space above and below them always balances. + let transcript_bottom = bar_top + 20.0; + let transcript_height = (height - transcript_bottom - (orb_top + room + 24.0)).max(0.0); + let ui_settings = settings::current(cx); let artwork = ui_settings .new_thread_composer_background @@ -424,6 +568,29 @@ impl Shell { .as_ref() .map(|background| background.adjustment) .unwrap_or_default(); + // A round veil of the page's own colour behind the orb, its edge a + // long blur: the picture recedes evenly all around it, whatever the + // picture holds there, so the orb always stands clear of it. + let (veil, veil_spread, veil_blur) = STAGE_ORB_VEIL; + let halo = div() + .absolute() + .top(px(orb_top)) + .left(px(orb_left)) + .size(px(room)) + .rounded_full() + .shadow(vec![gpui::BoxShadow { + // The page's colour at the veil's own strength: on frost + // the page is see-through, so its alpha is set, not scaled. + color: gpui::Hsla { + a: veil, + ..theme.bg + }, + offset: gpui::point(px(0.0), px(0.0)), + blur_radius: px(room * veil_blur), + spread_radius: px(room * veil_spread), + inset: false, + }]); + // The new-thread hero's artwork with only its soft bottom fade: no // cutout around the orb, so no light channel splits the picture. let hero = artwork.map(|artwork| { @@ -432,7 +599,7 @@ impl Shell { .top_0() .left_0() .w(px(width)) - .h(px(new_thread_background_height(f32::from(viewport.height)))) + .h(px((height * STAGE_HERO.0).min(STAGE_HERO.1))) .opacity(new_thread_background_opacity(theme.is_frost())) .child( gpui::canvas( @@ -452,46 +619,156 @@ impl Shell { .inset_0(), ) }); - - let orb_block = div() - .relative() - .size(px(STAGE_ORB_BOX)) + // The orb: the composer's own material (its frost, tint and rim) in + // a disc, and the field inside it running to its edge. Its dots and + // their light never leave the disc. + let disc = div() + .size(px(room)) + .rounded_full() + .border_1() + .border_color(theme.composer_surface_border()) + .bg(theme.composer_surface_bg()) + .when(!theme.is_frost(), |el| el.shadow_lg()) .flex() .items_center() .justify_center() - .child(self.voice_stage_orb.clone()); + .child(glyph::field( + &self.voice_stage_field, + dots, + dots, + grid, + mode, + &inks, + Outline::Disc, + self.voice_orb_touch(), + )); + // The orb's place in the window, for the pointer over it. + let orb_at = (left + orb_left, orb_top); + let orb = div() + .id("voice-stage-orb") + .absolute() + .top(px(orb_top)) + .left(px(orb_left)) + .size(px(room)) + // The pointer over the orb lights the dots under it; the light + // stays a moment where it left them. + .on_mouse_move( + cx.listener(move |this, event: &gpui::MouseMoveEvent, _, cx| { + let (x, y) = ( + f32::from(event.position.x) - orb_at.0, + f32::from(event.position.y) - orb_at.1, + ); + let inside = (x - room / 2.0).hypot(y - room / 2.0) <= room / 2.0; + if inside && !this.reduced_motion { + this.voice_touch = Some((x, y, std::time::Instant::now())); + cx.notify(); + } + }), + ) + // Painted plain, never frosted: frost can neither fade nor + // scale, and a disc that pops into blur once it has arrived is + // worse than one that keeps the page's own surface throughout. + .child(disc.opacity(reveal)); - let center = div() - .relative() - .top(px(18.0 * (1.0 - reveal))) + // The transcript, as the conversation reads: the user's turns in + // their bubble on the right, Codex's (or one not yet attributed) on + // the left in the page's text. A short exchange sits on the call bar; + // a longer one fades out under the top edge. + let column = STAGE_TRANSCRIPT_WIDTH.min(width - 48.0).max(200.0); + let count = lines.len(); + let newest_key = lines + .last() + .and_then(|(item, _, _)| item.clone()) + .unwrap_or_else(|| format!("turns-{count}")); + // New words bring the newest turn into view; until then a reader + // scrolled back stays where they are. + let said: usize = lines.iter().map(|(_, text, _)| text.len()).sum(); + if self.voice_transcript_seen != (newest_key.clone(), said) { + self.voice_transcript_seen = (newest_key.clone(), said); + self.voice_transcript_scroll.scroll_to_bottom(); + } + let scrolled = f32::from(self.voice_transcript_scroll.offset().y) < -1.0; + let mut previous: Option> = None; + let rows: Vec = lines + .into_iter() + .enumerate() + .map(|(ix, (item, text, speaker))| { + let user = speaker == Some(VoiceRole::User); + let gap = match previous { + None => 0.0, + Some(before) if (before == Some(VoiceRole::User)) == user => 12.0, + Some(_) => 20.0, + }; + previous = Some(speaker); + let words: AnyElement = match (&newest, ix + 1 == count) { + (Some(veil), true) => { + veiled_caption(veil.text.clone(), &veil.spans, &theme, window) + .into_any_element() + } + _ => SharedString::from(text).into_any_element(), + }; + let body = div() + .min_w_0() + .text_size(crate::typography::ui_rems(16.0)) + .line_height(px(24.0)) + .text_color(theme.text) + .child(words); + // Codex's turns read on the page, yours in your bubble: two + // voices told apart by their place, with no glyph but the orb. + let row = div() + .w_full() + .flex() + .items_start() + .mt(px(gap)) + .when(user, |row| row.justify_end()) + .child(if user { + body.max_w(px(column * 0.8)) + .bg(crate::theme::user_bubble_bg()) + .rounded(px(Theme::BUBBLE_RADIUS)) + .px(px(16.0)) + .py(px(10.0)) + } else { + body.max_w(px(column * 0.86)) + }); + // Each turn arrives as the conversation's rows do. + let id = item.unwrap_or_else(|| format!("turn-{ix}")); + motion::fade_in(format!("voice-turn-{id}"), row).into_any_element() + }) + .collect(); + // The words sit centred in the band while they fit, the block + // re-centring as turns come (keyed by the newest, it settles into + // place with the rows' own entrance). Once they outgrow the band + // they fill it and scroll, the newest in view and the rest a scroll + // away, fading out under the top edge. + let transcript = div() + .absolute() + .left(px((width - column) / 2.0)) + .w(px(column)) + .bottom(px(transcript_bottom)) + .h(px(transcript_height)) .flex() .flex_col() - .items_center() - .child(orb_block) - .child( + .justify_center() + .child(motion::fade_in( + format!("voice-stage-transcript-{newest_key}"), div() - .relative() - .mt(px(10.0)) - .w(px(560.0)) - .max_w(px((width - 48.0).max(200.0))) - .h(px(48.0)) - .overflow_hidden() - .text_center() - .text_size(crate::typography::ui_rems(14.0)) - .line_height(px(22.0)) - .text_color(theme.text_muted) - .child(veiled_caption(caption.text, &caption.spans, &theme, window)) - .children(caption.previous.map(|(previous, opacity)| { + .w_full() + .max_h(px(transcript_height)) + .child(crate::edge_fade::edge_faded( + 32.0, + scrolled, + false, div() - .absolute() - .top_0() - .left_0() - .right_0() - .opacity(opacity) - .child(previous) - })), - ); - + .id("voice-stage-transcript") + .w_full() + .max_h(px(transcript_height)) + .overflow_y_scroll() + .track_scroll(&self.voice_transcript_scroll) + .flex() + .flex_col() + .children(rows), + )), + )); let controls = self.render_voice_call_bar(&theme, awaiting, chat_id, cx); // The stage occludes the conversation titlebar. Restore its native // drag and double-click behavior above the stage artwork and controls. @@ -533,23 +810,15 @@ impl Shell { } })) .child(div().absolute().inset_0().children(hero)) - .child( - div() - .absolute() - .inset_0() - .flex() - .flex_col() - .items_center() - .justify_center() - .pb(px(64.0)) - .child(center), - ) + .child(halo) + .child(orb) + .child(transcript) .child( div() .absolute() .left_0() .right_0() - .bottom(px(32.0 - 16.0 * (1.0 - reveal))) + .bottom(px(28.0 - 12.0 * (1.0 - reveal))) .flex() .justify_center() .child(controls), @@ -559,8 +828,8 @@ impl Shell { ) } - /// Video-call style bar: session time, the call controls grouped around - /// a red hang-up, and the way back to the chats. + /// The call's controls in a floating pill, led by its glyph: the session + /// clock, the microphone, the transcript, a red hang-up and the way back. fn render_voice_call_bar( &mut self, theme: &Theme, @@ -574,37 +843,26 @@ impl Shell { let elapsed = voice .active_since .map(|since| crate::voice::format_elapsed(since.elapsed().as_secs())); - let live_tone = if awaiting { - popup.warning - } else if muted { - popup.text_faint - } else { - popup.success - }; - // Session clock, like a meeting's running time. - let clock = div() + // Who the call is with and for how long: the orb above is the + // call's one glyph here. + let session = div() .flex() .items_center() .gap(px(8.0)) - .pl(px(10.0)) - .pr(px(6.0)) - .child(div().size(px(7.0)).rounded_full().bg(live_tone)) .child( div() - .min_w(px(40.0)) .font_family(popup.font_mono.clone()) - .text_size(crate::typography::ui_rems(13.0)) + .text_size(crate::typography::ui_rems(12.0)) .text_color(popup.text_muted) .child(SharedString::from(elapsed.unwrap_or_else(|| "–:––".into()))), - ); - - let clock = clock.children(voice.host_name.clone().map(|name| { - div() - .text_size(crate::typography::ui_rems(12.0)) - .text_color(popup.text_faint) - .child(name) - })); + ) + .children(voice.host_name.clone().map(|name| { + div() + .text_size(crate::typography::ui_rems(12.0)) + .text_color(popup.text_faint) + .child(name) + })); // Microphone: inverted plate while muted. let mic = round_control("voice-bar-mic", &popup, muted) @@ -627,56 +885,22 @@ impl Shell { } else { icons::MICROPHONE }) - .size(px(19.0)) + .size(px(16.0)) .text_color(if muted { popup.on_solid } else { popup.text }), ); - let transcript = chat_id.map(|chat_id| { - round_control("voice-bar-transcript", &popup, false) - .aria_label(if awaiting { - "Answer Codex in the transcript" - } else { - "Open the voice transcript" - }) - .tooltip(stage_tooltip(if awaiting { - "Codex is asking you something" - } else { - "Transcript" - })) - .on_click(cx.listener(move |this, _, _, cx| this.open_chat(chat_id.clone(), cx))) - .child( - icon(icons::CHAT_ROUND_LINE) - .size(px(19.0)) - .text_color(if awaiting { popup.warning } else { popup.text }), - ) - .when(awaiting, |el| { - el.child( - div() - .absolute() - .top(px(9.0)) - .right(px(9.0)) - .size(px(8.0)) - .rounded_full() - .border_2() - .border_color(popover::surface_bg(&popup)) - .bg(popup.warning), - ) - }) - }); - - // The hang-up: the one saturated control, wide enough to never be - // mistaken for its neighbours. + // The hang-up: the one saturated control. let end = div() .id("voice-bar-end") .role(gpui::Role::Button) .aria_label("End voice") .tab_index(0) .h(px(BAR_CONTROL)) - .px(px(20.0)) + .px(px(14.0)) .rounded_full() .flex() .items_center() - .gap(px(8.0)) + .gap(px(6.0)) .cursor_pointer() .bg(motion::hover_blend( "voice-bar-end", @@ -689,47 +913,75 @@ impl Shell { .on_click(cx.listener(|this, _, _, cx| this.end_voice(cx))) .child( icon(icons::PHONE_HANG_UP) - .size(px(19.0)) + .size(px(16.0)) .text_color(gpui::white()), ) .child( div() - .text_size(crate::typography::ui_rems(13.0)) + .text_size(crate::typography::ui_rems(12.0)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(gpui::white()) .child(SharedString::from("End")), ); + // The way back: to the call's own conversation, where Codex's + // question (if it asked one) waits; without one, to the chats. let back = round_control("voice-bar-back", &popup, false) - .aria_label("Back to chats") - .tooltip(stage_tooltip("Back to chats · Esc")) - .on_click(cx.listener(|this, _, _, cx| this.set_voice_stage_open(false, cx))) + .aria_label(if awaiting { + "Answer Codex in the conversation" + } else { + "Back to the conversation" + }) + .tooltip(stage_tooltip(if awaiting { + "Codex is asking you something" + } else { + "Back to the conversation · Esc" + })) + .on_click(cx.listener(move |this, _, _, cx| match chat_id.clone() { + Some(chat_id) => this.open_chat(chat_id, cx), + None => this.set_voice_stage_open(false, cx), + })) .child( icon(icons::ALT_ARROW_DOWN) - .size(px(19.0)) - .text_color(popup.text_muted), - ); + .size(px(16.0)) + .text_color(if awaiting { + popup.warning + } else { + popup.text_muted + }), + ) + .when(awaiting, |el| { + el.child( + div() + .absolute() + .top(px(6.0)) + .right(px(6.0)) + .size(px(7.0)) + .rounded_full() + .border_2() + .border_color(popover::surface_bg(&popup)) + .bg(popup.warning), + ) + }); - let divider = || div().w(px(1.0)).h(px(24.0)).mx(px(4.0)).bg(popup.border); + // Concentric: the bar's radius is its controls' plus its padding. let radius = BAR_CONTROL / 2.0 + BAR_PAD; let bar = div() .flex() .items_center() .gap(px(6.0)) .p(px(BAR_PAD)) + .pl(px(BAR_PAD + 4.0)) .rounded(px(radius)) .border_1() .border_color(popup.border.opacity(0.7)) .bg(popover::surface_bg(&popup)) .when(!popup.is_frost(), |el| el.shadow_lg()) .text_color(popup.text) - .child(clock) - .child(divider()) + .child(session.mr(px(10.0))) .child(mic) - .children(transcript) - .child(end) - .child(divider()) - .child(back); + .child(back) + .child(end); crate::frost::frosted(radius, 18.0, bar).into_any_element() } } @@ -742,7 +994,7 @@ fn veiled_caption( window: &Window, ) -> gpui::StyledText { let mut run = window.text_style().to_run(text.len()); - run.color = theme.text_muted; + run.color = theme.text; let runs = crate::markdown::veil::apply_veil(vec![run], spans); gpui::StyledText::new(text).with_runs(runs) } @@ -1042,9 +1294,7 @@ mod tests { } #[gpui::test] - fn composer_orb_shows_only_on_the_orchestrator_chat_and_returns_to_the_call( - cx: &mut gpui::TestAppContext, - ) { + fn composer_orb_shows_on_every_chat_and_returns_to_the_call(cx: &mut gpui::TestAppContext) { struct OrbHost(Entity); impl Render for OrbHost { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { @@ -1098,8 +1348,9 @@ mod tests { cx.update(|window, cx| window.draw(cx).clear()); }; + // The call stays in reach from every chat, not only the orchestrator's. select("another-chat", cx); - assert!(cx.debug_bounds("voice-composer-orb").is_none()); + assert!(cx.debug_bounds("voice-composer-orb").is_some()); select(&orchestrator, cx); let orb = cx.debug_bounds("voice-composer-orb").unwrap(); diff --git a/crates/ui/src/voice.rs b/crates/ui/src/voice.rs index b5b62e481c..80f17e7486 100644 --- a/crates/ui/src/voice.rs +++ b/crates/ui/src/voice.rs @@ -2,14 +2,36 @@ //! projectless Codex chat, resumed across calls, and survives navigation //! between threads; only an explicit end, an engine change or a provider //! failure closes it. No automatic reconnect or microphone resume. -use crate::orb::OrbState; use crate::state::EngineHandle; use gpui::{Context, Task}; use gpui_tokio::Tokio; +use zeron_orb::OrbState; use zeron_proto::voice::*; +pub mod glyph; +mod hearing; mod permissions; mod remote; +/// Whether a voice call is listening to the microphone right now: live and +/// not muted. Dictation stands aside while it is, so the user never speaks +/// to two things at once; muting the call hands the microphone back. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub struct VoiceListening(pub bool); +impl gpui::Global for VoiceListening {} + +/// Tells every composer whether a call has the microphone. +pub(crate) fn publish_listening(listening: bool, cx: &mut gpui::App) { + if cx.try_global::().copied() != Some(VoiceListening(listening)) { + cx.set_global(VoiceListening(listening)); + } +} + +/// Whether a call is listening (see [`VoiceListening`]). +pub fn listening(cx: &gpui::App) -> bool { + cx.try_global::() + .is_some_and(|listening| listening.0) +} + pub enum VoiceControl { Mute(bool), } @@ -42,6 +64,12 @@ pub struct VoiceController { codex_check: Option>, /// This call played its start sound and owes its end sound. cued: bool, + /// Both voices, band by band, as the glyph follows them. + hearing: hearing::Hearing, + /// A scripted call ([`Self::preview`]): its voices are made up, so the + /// microphone is not read. The turn it is in and the words it has said. + previewing: bool, + preview_said: (String, usize), } impl Default for VoiceController { fn default() -> Self { @@ -66,10 +94,23 @@ impl Default for VoiceController { codex_engine: None, codex_check: None, cued: false, + hearing: Default::default(), + previewing: false, + preview_said: Default::default(), } } } impl VoiceController { + /// A scripted call ([`Self::preview`]), which no engine holds. + pub fn previewing(&self) -> bool { + self.previewing + } + + /// Whether the call has the microphone: live and not muted. + pub(crate) fn has_microphone(&self) -> bool { + self.is_live() && !self.muted() + } + /// Voice can be offered here. An unread catalog counts as yes, so a slow /// or failed read never hides it; a live call or its failure stays. pub fn offered(&self) -> bool { @@ -134,9 +175,6 @@ impl VoiceController { cx: &mut Context, ) { self.cancel(cx); - // The call sounds as it is placed, well before the microphone opens. - crate::sound::play_voice(true); - self.cued = true; self.engine = Some(engine.clone()); self.phase = VoicePhase::Checking; self.chat_id = None; @@ -199,10 +237,10 @@ impl VoiceController { cx.notify(); } - /// Ending now sounds: a hang-up or a connected call ending. A failure to - /// connect only explains itself. + /// Ending now sounds: only a call that connected, and so sounded its + /// start, sounds its end. A failure to connect only explains itself. fn end_sounds(&self) -> bool { - self.cued && (self.active_since.is_some() || self.reason.is_none()) + self.cued } fn reset_session(&mut self) { @@ -218,6 +256,185 @@ impl VoiceController { self.task = None; self.stage_open = false; self.active_since = None; + self.hearing.follow(false, false, false); + self.previewing = false; + } + + /// A scripted call, for looking at the call's surfaces without a provider + /// or any usage: `ZERON_VOICE_PREVIEW=1` in a debug build, as the phone's + /// `-voice-preview`. Codex speaks, the user answers, Codex works and then + /// waits on an answer, every 14 seconds; the stage opens on it. + #[cfg(debug_assertions)] + pub fn preview(&mut self, cx: &mut Context) { + self.cancel(cx); + let epoch = self.epoch; + self.previewing = true; + self.host_name = Some("This Mac".into()); + self.reduce( + VoiceEvent::Snapshot { + snapshot: VoiceSnapshot { + session_id: "voice-preview".into(), + chat_id: "voice-preview".into(), + generation: 1, + phase: VoicePhase::Active, + muted: false, + playing: false, + work: VoiceWork::Idle, + reason: None, + voice: None, + voices: Vec::new(), + }, + }, + cx, + ); + // `ZERON_VOICE_PREVIEW=` holds the preview in that chat, so + // its tray shows over the composer; any other value holds it in + // none. Either way no engine change ends it. `ZERON_VOICE_PREVIEW_STAGE=0` + // starts with the stage closed. + self.chat_id = std::env::var("ZERON_VOICE_PREVIEW") + .ok() + .filter(|chat| chat != "1"); + self.stage_open = std::env::var("ZERON_VOICE_PREVIEW_STAGE").as_deref() != Ok("0"); + // `ZERON_VOICE_PREVIEW_CONNECTING=` starts the call connecting + // for that long first. + if preview_secs("ZERON_VOICE_PREVIEW_CONNECTING").is_some() { + self.phase = VoicePhase::Starting; + if let Some(snapshot) = &mut self.snapshot { + snapshot.phase = VoicePhase::Starting; + } + } + let started = std::time::Instant::now(); + self.task = Some(cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(std::time::Duration::from_millis(40)) + .await; + let t = started.elapsed().as_secs_f32(); + let going = this.update(cx, |voice, cx| { + voice.epoch == epoch && { + voice.preview_at(t, cx); + true + } + }); + if !going.unwrap_or(false) { + break; + } + } + })); + cx.notify(); + } + + /// The preview's script at `t` seconds. + #[cfg(debug_assertions)] + fn preview_at(&mut self, t: f32, cx: &mut Context) { + // Connecting first (`ZERON_VOICE_PREVIEW_CONNECTING=`), then + // the call goes live. + if let Some(connecting) = preview_secs("ZERON_VOICE_PREVIEW_CONNECTING") { + if t < connecting { + cx.notify(); + return; + } + if self.phase != VoicePhase::Active + && let Some(mut snapshot) = self.snapshot.clone() + { + snapshot.phase = VoicePhase::Active; + self.reduce(VoiceEvent::Snapshot { snapshot }, cx); + } + } + let t = t - preview_secs("ZERON_VOICE_PREVIEW_CONNECTING").unwrap_or(0.0); + const SCRIPT: [(f32, VoiceWork, &str, &str); 5] = [ + ( + 4.0, + VoiceWork::Idle, + "codex", + "Three tests fail in the parser. Want me to fix them and open a PR?", + ), + ( + 4.0, + VoiceWork::Idle, + "user", + "Yes, go ahead, and ping me when it's green.", + ), + ( + 3.0, + VoiceWork::Working, + "codex", + "Running the parser tests…", + ), + ( + 3.0, + VoiceWork::AwaitingInput, + "codex", + "Should I update the fixture or the parser?", + ), + // The user mutes to think: the call stays, the microphone rests. + (2.0, VoiceWork::Idle, "muted", ""), + ]; + let lap: f32 = SCRIPT.iter().map(|turn| turn.0).sum(); + let (mut at, mut into) = (t % lap, 0usize); + while at >= SCRIPT[into].0 { + at -= SCRIPT[into].0; + into += 1; + } + let (length, work, who, said) = SCRIPT[into]; + // Syllables about five a second inside phrases. + let phrase = (at / length * std::f32::consts::PI) + .sin() + .max(0.0) + .powf(0.5); + let syllable = (t * 4.7 * std::f32::consts::TAU).sin().max(0.0).powf(1.4); + let level = (phrase * (0.2 + 0.8 * syllable) * f32::from(u16::MAX) * 0.5) as u16; + let talking = work == VoiceWork::Idle && who != "muted"; + if let Some(snapshot) = &mut self.snapshot { + snapshot.muted = who == "muted"; + } + let (microphone, speaker) = match (talking, who) { + (true, "user") => (level, 0), + (true, _) => (0, level), + _ => (0, 0), + }; + if let Some(snapshot) = &mut self.snapshot { + snapshot.work = work; + } + // Words stream in as deltas, as a provider's partials do. + let words: Vec<&str> = said.split(' ').filter(|word| !word.is_empty()).collect(); + let shown = ((at / length * 1.4 * words.len() as f32) as usize + 1).min(words.len()); + let item = format!("preview-{into}-{}", (t / lap) as u32); + if self.preview_said.0 != item { + self.preview_said = (item.clone(), 0); + } + let from = self.preview_said.1; + if shown > from { + let delta = words[from..shown].join(" "); + let delta = if from == 0 { + delta + } else { + format!(" {delta}") + }; + self.preview_said.1 = shown; + self.reduce( + VoiceEvent::Partial { + generation: 1, + item_id: Some(item), + text: delta, + // Named as the turn begins, as the provider does. + role: Some(if who == "user" { + VoiceRole::User + } else { + VoiceRole::Assistant + }), + }, + cx, + ); + } + self.reduce( + VoiceEvent::Levels { + generation: 1, + microphone, + speaker, + }, + cx, + ); } /// End the session (any phase). The provider stop runs in the background. @@ -264,6 +481,8 @@ impl VoiceController { if let Some(snapshot) = &mut self.snapshot { snapshot.muted = !muted; } + self.hearing + .follow(self.phase == VoicePhase::Active, !self.previewing, !muted); cx.notify(); if let Some(controls) = &self.controls { if controls.try_send(VoiceControl::Mute(!muted)).is_err() { @@ -282,6 +501,24 @@ impl VoiceController { orb_state(self.phase, self.snapshot.as_ref()) } + /// Both voices right now, band by band, for the glyph to follow. + pub(crate) fn heard(&self) -> glyph::Voice { + self.hearing.voice() + } + + /// What the glyph shows between voices. + pub(crate) fn glyph_mode(&self) -> glyph::Mode { + if self.muted() { + return glyph::Mode::Muted; + } + match self.orb_state() { + OrbState::Connecting => glyph::Mode::Connecting, + OrbState::Working | OrbState::Searching => glyph::Mode::Thinking, + OrbState::Solving => glyph::Mode::Asking, + _ => glyph::Mode::Listening, + } + } + /// Normalized 0…1 microphone loudness; zero while muted. pub fn microphone_level(&self) -> f32 { if self.muted() { @@ -316,6 +553,13 @@ impl VoiceController { self.phase = snapshot.phase; if self.phase == VoicePhase::Active && self.active_since.is_none() { self.active_since = Some(std::time::Instant::now()); + // The call sounds once it connects, not as it is placed. + if !self.previewing && !cfg!(test) { + crate::sound::play_voice(true); + } + if !self.previewing { + self.cued = true; + } } self.snapshot = Some(snapshot); } @@ -323,12 +567,15 @@ impl VoiceController { generation, item_id, text, + role, } if self .snapshot .as_ref() .is_some_and(|s| s.generation == generation) => { - if !self.caption.partial(item_id, &text) { + // A turn past the caption's ceiling is a stream that never + // ends, not speech: the call is given up rather than fed. + if !self.caption.partial(item_id, &text, role) { self.cancel(cx); self.phase = VoicePhase::Failed; self.reason = Some(VoiceRejection::Overflow); @@ -345,6 +592,9 @@ impl VoiceController { { self.microphone_level = microphone; self.speaker_level = speaker; + let levels = (self.microphone_level(), self.speaker_level()); + self.hearing + .levels(levels.0, levels.1, std::time::Instant::now()); if let Some(snapshot) = &mut self.snapshot { let threshold = if snapshot.playing { 328 } else { 655 }; if speaker >= threshold { @@ -384,6 +634,12 @@ impl VoiceController { } _ => return, } + // The microphone's spectrum is read while the call is live and heard. + self.hearing.follow( + self.phase == VoicePhase::Active, + !self.previewing, + self.muted(), + ); cx.notify(); } pub fn reason_text(&self) -> &'static str { @@ -475,16 +731,30 @@ mod tests { assert!(voice.offered(), "a call in progress keeps its trigger"); } - #[test] - fn only_a_hang_up_or_a_connected_call_sounds_its_end() { - let mut voice = VoiceController::default(); - assert!(!voice.end_sounds(), "no call was placed"); - voice.cued = true; - assert!(voice.end_sounds(), "hanging up while connecting"); - voice.reason = Some(VoiceRejection::Busy); - assert!(!voice.end_sounds(), "a failure to connect stays quiet"); - voice.active_since = Some(std::time::Instant::now()); - assert!(voice.end_sounds(), "a connected call that drops"); + #[gpui::test] + fn a_call_sounds_once_it_connects_and_only_then_sounds_its_end(cx: &mut gpui::TestAppContext) { + let voice = cx.new(|_| VoiceController::default()); + voice.update(cx, |voice, cx| { + let mut connecting = snapshot(); + connecting.phase = VoicePhase::Starting; + voice.reduce( + VoiceEvent::Snapshot { + snapshot: connecting, + }, + cx, + ); + assert!(!voice.end_sounds(), "placed, not connected: silent"); + voice.reduce( + VoiceEvent::Snapshot { + snapshot: snapshot(), + }, + cx, + ); + assert!( + voice.end_sounds(), + "connected: it sounded, and owes its end" + ); + }); } #[gpui::test] @@ -504,6 +774,7 @@ mod tests { generation: 1, item_id: None, text: "old".into(), + role: None, }, cx, ); @@ -513,6 +784,7 @@ mod tests { generation: 2, item_id: None, text: "current".into(), + role: None, }, cx, ); @@ -558,6 +830,7 @@ mod tests { generation: 2, item_id: Some(item.into()), text: text.into(), + role: None, }; voice.reduce(partial("user", "Open the test"), cx); voice.reduce(partial("answer", "Sure, "), cx); @@ -633,3 +906,9 @@ mod tests { ); } } + +/// A preview moment from the environment, in seconds. +#[cfg(debug_assertions)] +fn preview_secs(name: &str) -> Option { + std::env::var(name).ok()?.parse().ok() +} diff --git a/crates/ui/src/voice/glyph.rs b/crates/ui/src/voice/glyph.rs new file mode 100644 index 0000000000..5e992c4b1e --- /dev/null +++ b/crates/ui/src/voice/glyph.rs @@ -0,0 +1,135 @@ +//! The voice glyph, painted through GPUI: the shared engine +//! ([`zeron_orb::glyph`]) decides every dot, this module lays them out and +//! lights them. Fields that stand alone are sized in dots; a beam fills the +//! box it is given, counting its dots at paint time, so it always reaches +//! that box's edges. +use gpui::{Bounds, Hsla, IntoElement, Pixels, Rgba, Styled, Window, point, px, size}; +pub use zeron_orb::glyph::{ + BANDS, Beam, Cell, Field, Grid, Inks, Mode, Outline, Rings, Skip, Touch, Voice, +}; +use zeron_orb::glyph::{Rgb, look}; + +use crate::theme::Theme; + +/// The dots of every beam: the dictation track's, the call strip's and the +/// footer's alike, so the one voice reads the same wherever it runs. +pub const BEAM_DOT: f32 = 2.4; + +fn rgb(colour: Hsla) -> Rgb { + let Rgba { r, g, b, .. } = colour.into(); + [r, g, b] +} + +fn hsla([r, g, b]: Rgb, a: f32) -> Hsla { + Rgba { r, g, b, a }.into() +} + +/// Codex in sunrise, the user in the theme's accent. +pub fn inks(theme: &Theme) -> Inks { + Inks::new(theme.glyph.rows().map(rgb)) +} + +/// Lights `cells` laid out on `grid` from `origin`: one quad a dot, its +/// light told by colour alone, so even a large field stays cheap. +fn paint(window: &mut Window, origin: gpui::Point, grid: Grid, cells: &[Cell]) { + let pitch = grid.pitch(); + // Every dot lands on whole device pixels, its centre and its size alike: + // a dot straddling pixels differently from its neighbour reads as a + // second, slower pattern over the grid (moiré), not as light. + let scale = window.scale_factor().max(1.0); + let snap = |v: f32| (v * scale).round() / scale; + for cell in cells { + let look = look(cell); + if look.alpha < 0.01 { + continue; + } + let across = snap(grid.dot * look.size).max(1.0 / scale); + let centre = ( + f32::from(origin.x) + cell.col * pitch + grid.dot / 2.0, + f32::from(origin.y) + cell.row * pitch + grid.dot / 2.0, + ); + let at = Bounds::new( + point( + px(snap(centre.0 - across / 2.0)), + px(snap(centre.1 - across / 2.0)), + ), + size(px(across), px(across)), + ); + window + .paint_quad(gpui::fill(at, hsla(look.fill, look.alpha)).corner_radii(px(across / 2.0))); + } +} + +/// A field `cols` × `rows` dots on `grid`, inside `outline`, with a +/// pointer's `touch` on it (in points from its corner) if there is one. +#[allow(clippy::too_many_arguments)] +pub fn field( + field: &Field, + cols: usize, + rows: usize, + grid: Grid, + mode: Mode, + inks: &Inks, + outline: Outline, + touch: Option, +) -> impl IntoElement { + let (w, h) = grid.extent(cols, rows); + let cells = field.cells_touched(cols, rows, grid, mode, inks, outline, &[], touch); + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| paint(window, bounds.origin, grid, &cells), + ) + .w(px(w)) + .h(px(h)) + .flex_none() +} + +/// A matrix filling its box: `build` is asked for the dots once the box is +/// known, given how many fit and the rooms `skip` leaves, which are measured +/// from the box's corner and moved here onto the matrix's first dot. +fn filling( + grid: Grid, + skip: Vec, + build: impl FnOnce(usize, usize, &[Skip]) -> Vec + 'static, +) -> gpui::Canvas<()> { + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let ((cols, rows), (mx, my)) = + grid.fit(f32::from(bounds.size.width), f32::from(bounds.size.height)); + let skip: Vec<_> = skip.iter().map(|room| room.shifted(-mx, -my)).collect(); + let cells = build(cols, rows, &skip); + paint(window, bounds.origin + point(px(mx), px(my)), grid, &cells); + }, + ) +} + +/// A beam filling its box inside `outline`, whose radii are in points: it +/// meets the edges of what holds it and fades where it touches them. +pub fn beam( + beam: &Beam, + grid: Grid, + mode: Mode, + inks: Inks, + outline: Outline, +) -> gpui::Canvas<()> { + let beam = beam.clone(); + let outline = match outline { + Outline::Rounded(radius) => Outline::Rounded(radius / grid.pitch()), + Outline::Under(radius) => Outline::Under(radius / grid.pitch()), + other => other, + }; + // The matrix covers the box and runs past it: the box's own outline is + // where the dots fade out, so the beam fills it to its curves. + gpui::canvas( + |_, _, _| {}, + move |bounds, _, window, _| { + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let ((cols, rows), (mx, my)) = grid.cover(width, height); + let area = (width / grid.pitch(), height / grid.pitch()); + let cells = beam.cells_over(cols, rows, area, mode, &inks, outline); + window.paint_quad(gpui::fill(bounds, gpui::transparent_black())); + paint(window, bounds.origin + point(px(mx), px(my)), grid, &cells); + }, + ) +} diff --git a/crates/ui/src/voice/hearing.rs b/crates/ui/src/voice/hearing.rs new file mode 100644 index 0000000000..e53078d06f --- /dev/null +++ b/crates/ui/src/voice/hearing.rs @@ -0,0 +1,93 @@ +//! What a call sounds like, for its glyph: each of its two voices as how loud +//! a few bands of it are, lowest first. +//! +//! The user's voice is measured from the microphone itself +//! ([`zeron_voice::spectrum`]), beside the call's own capture: levels only, +//! and only while the call is live and the microphone is not muted. Codex's +//! voice never leaves the call's audio process, which reports only how loud +//! it is, so its bands are the rhythms of that loudness instead +//! ([`Rhythm`]): the phrase, the syllable, the attack. +use std::time::Instant; + +use zeron_orb::glyph::{Rhythm, Voice}; +use zeron_voice::spectrum::Listener; + +const _: () = assert!(zeron_voice::spectrum::BANDS == zeron_orb::glyph::BANDS); + +/// The two voices of a call. +#[derive(Default)] +pub(crate) struct Hearing { + ears: Option, + user: Rhythm, + agent: Rhythm, + /// When Codex's voice was last heard playing: while it plays, what the + /// microphone hears is mostly Codex through the speakers, so the user's + /// voice is not read from it. + playing: Option, +} + +/// Codex's voice counts as playing this long after its last sound, and a +/// speaker peak over this much counts as sound. +const PLAYING_HOLD: std::time::Duration = std::time::Duration::from_millis(250); +const PLAYING_PEAK: f32 = 0.02; + +impl Hearing { + /// Follows a call while it is `open`, listening to the microphone if + /// `listen` (measuring nothing while it is `muted`), and forgets it + /// otherwise. + pub(crate) fn follow(&mut self, open: bool, listen: bool, muted: bool) { + if !open { + *self = Self::default(); + return; + } + if !listen { + self.ears = None; + return; + } + // Never from a test, and not when asked not to: without it the + // user's voice is followed by its loudness, as Codex's is. + if self.ears.is_none() + && !cfg!(test) + && std::env::var("ZERON_VOICE_SPECTRUM").as_deref() != Ok("0") + { + self.ears = Listener::start().ok(); + } + if let Some(ears) = &self.ears { + ears.pause(muted); + } + } + + /// The call's own meters: the peak of each voice, 0–1. + pub(crate) fn levels(&mut self, microphone: f32, speaker: f32, now: Instant) { + self.user.hear(microphone, now); + self.agent.hear(speaker, now); + if speaker > PLAYING_PEAK { + self.playing = Some(now); + } + } + + /// Whether Codex's voice is playing now, or was a moment ago. + fn playing(&self, now: Instant) -> bool { + self.playing + .is_some_and(|at| now.saturating_duration_since(at) < PLAYING_HOLD) + } + + pub(crate) fn voice(&self) -> Voice { + // Codex's voice comes back through the microphone as a voice of its + // own: while Codex plays, the user is taken to be listening, and + // only Codex shows. The call's audio has its own echo cancelling; + // the glyph's microphone does not. + let user = if self.playing(Instant::now()) { + Default::default() + } else { + self.ears + .as_ref() + .and_then(Listener::levels) + .unwrap_or(self.user.bands()) + }; + Voice { + user, + agent: self.agent.bands(), + } + } +} diff --git a/crates/voice/src/lib.rs b/crates/voice/src/lib.rs index e475e93c2a..12a805a9af 100644 --- a/crates/voice/src/lib.rs +++ b/crates/voice/src/lib.rs @@ -13,8 +13,11 @@ use std::{ }; mod resample; +pub mod spectrum; pub const MAX_SECONDS: usize = 60; +/// Where the speech model lives under a data directory, on every device. +pub const MODEL_DIR: &str = "models/parakeet-tdt-0.6b-v3-int8"; #[derive(serde::Deserialize)] pub struct Artifact { pub name: String, @@ -167,6 +170,47 @@ where } } } +/// Audio arriving from elsewhere, already mono at `rate`: a host takes a +/// phone's dictation through one of these in place of a microphone. Samples +/// are kept in memory for the length of the dictation and nowhere else. +pub struct Feed { + audio: Arc