diff --git a/Cargo.lock b/Cargo.lock index db224756b6..2fccfca7c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7357,7 +7357,7 @@ dependencies = [ [[package]] name = "hanzo-dev" -version = "0.7.4" +version = "0.7.6" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 72b1c9c3ad..a43403d63b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,7 +13,7 @@ resolver = "2" # hanzo-dev takes it, and every crate beneath reads it at run time (the fork's # hanzo::version), so a release recompiles hanzo-dev alone; Dev's libraries stay # 0.0.0 for the same reason. -version = "0.7.4" +version = "0.7.6" # Track the edition for all workspace crates in one place. Individual # crates can still override this value, but keeping it here means new # crates created with `cargo new -w ...` automatically inherit the 2024 diff --git a/crates/hanzo-dev/src/ascii.rs b/crates/hanzo-dev/src/ascii.rs index 41948ea9a2..ddb9cb8b0c 100644 --- a/crates/hanzo-dev/src/ascii.rs +++ b/crates/hanzo-dev/src/ascii.rs @@ -1,198 +1,12 @@ -//! Dependency-free, terminal-resolution 3D renderer for the canonical Hanzo mark. -//! Only one rigid Y-axis transform; the actual 2D outline is hanzo::MARK. -//! -//! Each Braille cell encodes eight surface samples. The logo never exceeds -//! the caller-supplied bounds, even at 80 columns or on terminal resize. - -use std::f64::consts::TAU; -use std::sync::LazyLock; - -pub(crate) const MAX_WIDTH: u16 = 60; +//! Native idle adapter. Geometry, lighting, sampling and spin live in hanzo::logo, +//! exactly as in the default welcome TUI. +pub(crate) const MAX_WIDTH: u16 = hanzo::logo::MAX_COLUMNS; pub(crate) const MAX_HEIGHT: u16 = 21; -const GRID: usize = 160; -const HALF_DEPTH: f64 = 0.33; -const DOTS: [u8; 8] = [1, 8, 2, 16, 4, 32, 64, 128]; -const CUBE_PITCH: f64 = 2.0 / 12.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct Cell { pub(crate) glyph: char, - pub(crate) shade: u8, -} - -impl Default for Cell { - fn default() -> Self { - Self { - glyph: ' ', - shade: 0, - } - } -} - -fn number(path: &str, index: &mut usize) -> f64 { - let bytes = path.as_bytes(); - while *index < bytes.len() && bytes[*index].is_ascii_whitespace() { - *index += 1; - } - let start = *index; - while *index < bytes.len() - && (bytes[*index].is_ascii_digit() || bytes[*index] == b'.' || bytes[*index] == b'-') - { - *index += 1; - } - path[start..*index].parse().unwrap_or(0.0) -} - -fn parse_mark() -> Vec> { - let path = hanzo::MARK.0; - let mut index = 0; - let mut current = [0.0, 0.0]; - let mut shape = Vec::new(); - let mut polygons = Vec::new(); - while index < path.len() { - let command = path.as_bytes()[index] as char; - index += 1; - match command { - 'M' => { - if !shape.is_empty() { - polygons.push(std::mem::take(&mut shape)); - } - current = [number(path, &mut index), number(path, &mut index)]; - shape.push(current); - } - 'L' => { - current = [number(path, &mut index), number(path, &mut index)]; - shape.push(current); - } - 'H' => { - current[0] = number(path, &mut index); - shape.push(current); - } - 'V' => { - current[1] = number(path, &mut index); - shape.push(current); - } - 'Z' => { - if !shape.is_empty() { - polygons.push(std::mem::take(&mut shape)); - } - } - _ => {} - } - } - if !shape.is_empty() { - polygons.push(shape); - } - polygons -} - -fn inside_polygon(point: [f64; 2], polygon: &[[f64; 2]]) -> bool { - let mut inside = false; - let mut prev = polygon[polygon.len() - 1]; - for &next in polygon { - if (next[1] > point[1]) != (prev[1] > point[1]) { - let intercept = - (prev[0] - next[0]) * (point[1] - next[1]) / (prev[1] - next[1]) + next[0]; - if point[0] < intercept { - inside = !inside; - } - } - prev = next; - } - inside -} - -static MASK: LazyLock> = LazyLock::new(|| { - let polygons = parse_mark(); - let mut mask = vec![false; GRID * GRID]; - for iy in 0..GRID { - for ix in 0..GRID { - let point = [ - (ix as f64 + 0.5) / GRID as f64 * 67.0, - (iy as f64 + 0.5) / GRID as f64 * 67.0, - ]; - mask[iy * GRID + ix] = polygons.iter().any(|p| inside_polygon(point, p)); - } - } - mask -}); - -fn filled(x: f64, y: f64) -> bool { - let fx = (x + 1.0) * 0.5; - let fy = (y + 1.0) * 0.5; - if !(0.0..1.0).contains(&fx) || !(0.0..1.0).contains(&fy) { - return false; - } - let ix = (fx * GRID as f64) as usize; - let iy = (fy * GRID as f64) as usize; - MASK[iy * GRID + ix] -} - -fn face_brightness(normal: [f64; 3]) -> f64 { - (0.27 + 0.72 * (normal[0] * -0.45 + normal[1] * 0.55 + normal[2] * 0.70).max(0.0)).min(1.0) -} - -fn ray(x: f64, y: f64, yaw: f64) -> Option { - let (s, c) = yaw.sin_cos(); - let origin = [c * x - s * 4.5, y, s * x + c * 4.5]; - let direction = [s, 0.0, -c]; - let bounds = [(-1.0, 1.0), (-1.0, 1.0), (-HALF_DEPTH, HALF_DEPTH)]; - let (mut enter, mut exit) = (0.0_f64, 20.0_f64); - for axis in 0..3 { - if direction[axis].abs() < 1e-10 { - if origin[axis] < bounds[axis].0 || origin[axis] > bounds[axis].1 { - return None; - } - continue; - } - let a = (bounds[axis].0 - origin[axis]) / direction[axis]; - let b = (bounds[axis].1 - origin[axis]) / direction[axis]; - enter = enter.max(a.min(b)); - exit = exit.min(a.max(b)); - if enter > exit { - return None; - } - } - let mut t = enter + 0.004; - while t <= exit && t < 20.0 { - let px = origin[0] + t * direction[0]; - let py = origin[1]; - let pz = origin[2] + t * direction[2]; - if filled(px, py) { - let local_normal = if (pz.abs() - HALF_DEPTH).abs() < 0.018 { - [0.0, 0.0, if pz > 0.0 { 1.0 } else { -1.0 }] - } else { - let dx = (filled(px - 0.026, py) as i8) - (filled(px + 0.026, py) as i8); - let dy = (filled(px, py - 0.026) as i8) - (filled(px, py + 0.026) as i8); - let len = (f64::from(dx).hypot(f64::from(dy))).max(1e-9); - if len < 0.5 { - [-direction[0], 0.0, -direction[2]] - } else { - [f64::from(dx) / len, f64::from(dy) / len, 0.0] - } - }; - let normal = [ - local_normal[0] * c + local_normal[2] * s, - local_normal[1], - -local_normal[0] * s + local_normal[2] * c, - ]; - let edge = |v: f64| { - let frac = ((v + 1.0) / CUBE_PITCH).rem_euclid(1.0); - frac.min(1.0 - frac) - }; - let seam = if local_normal[2].abs() > 0.5 { - edge(px).min(edge(py)) - } else if local_normal[0].abs() > local_normal[1].abs() { - edge(py).min(edge(pz)) - } else { - edge(px).min(edge(pz)) - }; - let surface = if seam < 0.035 { 0.81 } else { 1.0 }; - return Some(face_brightness(normal) * surface); - } - t += 0.013; - } - None + pub(crate) rgb: u32, } pub(crate) fn render(columns: u16, rows: u16, phase: f64) -> Vec> { @@ -201,57 +15,42 @@ pub(crate) fn render(columns: u16, rows: u16, phase: f64) -> Vec> { if columns == 0 || rows == 0 { return Vec::new(); } - let dot_cols = usize::from(columns) * 2; - let dot_rows = usize::from(rows) * 4; - let pitch = 2.65 / dot_rows as f64; - let yaw = phase.rem_euclid(1.0) * TAU; - let mut result = vec![vec![Cell::default(); usize::from(columns)]; usize::from(rows)]; - for row in 0..usize::from(rows) { - for col in 0..usize::from(columns) { - let mut dots = 0_u8; - let mut total = 0.0; - let mut count = 0_u32; - for (sub, bit) in DOTS.into_iter().enumerate() { - let dx = col * 2 + sub % 2; - let dy = row * 4 + sub / 2; - let x = (dx as f64 + 0.5 - dot_cols as f64 * 0.5) * pitch; - // SVG Y increases downward; the 2D face must not flip. - let y = (dy as f64 + 0.5 - dot_rows as f64 * 0.5) * pitch; - if let Some(value) = ray(x, y, yaw) { - dots |= bit; - total += value; - count += 1; - } - } - if count > 0 { - result[row][col] = Cell { - glyph: char::from_u32(0x2800 + u32::from(dots)).unwrap_or(' '), - shade: ((total / f64::from(count) * 23.0).round() as u8).min(23), - }; - } - } - } - result + let mut renderer = hanzo::logo::Renderer::default(); + let light = hanzo::logo::Lighting::terminal((210, 221, 235), (15, 20, 37)); + renderer + .frame(columns, rows, phase, &light) + .chunks(usize::from(columns)) + .map(|row| { + row.iter() + .map(|cell| Cell { + glyph: cell.glyph(), + rgb: cell.rgb, + }) + .collect() + }) + .collect() } #[cfg(test)] mod tests { use super::*; - - #[test] - fn geometry_comes_from_hanzo_brand() { - assert_eq!(parse_mark().len(), 5); - assert!(filled(-0.67, -0.67)); - assert!(!filled(0.0, -0.83)); - } - #[test] - fn exact_loop_and_80_column_bounds() { - let first = render(60, 21, 0.0); - assert_eq!(first, render(60, 21, 1.0)); - assert_eq!(first.len(), 21); - assert!(first.iter().all(|row| row.len() == 60)); - assert!(first.iter().flatten().any(|cell| cell.glyph != ' ')); - assert!(render(80, 24, 0.0).iter().all(|row| row.len() <= 60)); + fn native_and_welcome_frames_are_identical() { + let light = hanzo::logo::Lighting::terminal((210, 221, 235), (15, 20, 37)); + let mut shared = hanzo::logo::Renderer::default(); + for (width, height) in [(40, 14), (60, 21)] { + for phase in [0.0, 0.125, 0.25, 0.5, 1.0] { + let native = render(width, height, phase); + for (a, b) in native + .iter() + .flatten() + .zip(shared.frame(width, height, phase, &light)) + { + assert_eq!(a.rgb, b.rgb); + assert_eq!(a.glyph, b.glyph()); + } + } + } + assert!(render(0, 0, 0.0).is_empty()); } } diff --git a/crates/hanzo-dev/src/idle.rs b/crates/hanzo-dev/src/idle.rs index 3f2c2fa2c9..eea06e7434 100644 --- a/crates/hanzo-dev/src/idle.rs +++ b/crates/hanzo-dev/src/idle.rs @@ -48,7 +48,7 @@ fn plain_frame(columns: u16, rows: u16) -> String { .join("\n") } -fn render_frame( +fn encode_frame( output: &mut impl Write, columns: u16, rows: u16, @@ -69,16 +69,18 @@ fn render_frame( let y = rows.saturating_sub(height) / 2; for (index, line) in ascii::render(width, height, phase).iter().enumerate() { execute!(output, cursor::MoveTo(x, y + index as u16))?; - let mut previous: Option = None; + let mut previous: Option = None; for cell in line { - if cell.glyph != ' ' && (truecolor || ansi256) && previous != Some(cell.shade) { - let gray = 52 + u16::from(cell.shade) * 8; + if cell.glyph != ' ' && (truecolor || ansi256) && previous != Some(cell.rgb) { + let [_, r, g, b] = cell.rgb.to_be_bytes(); + let shade = + ((u16::from(r) + u16::from(g) + u16::from(b)) / 3).saturating_sub(8) / 10; if truecolor { - write!(output, "\x1b[38;2;{gray};{gray};{gray}m")?; + write!(output, "\x1b[38;2;{r};{g};{b}m")?; } else { - write!(output, "\x1b[38;5;{}m", 232 + cell.shade)?; + write!(output, "\x1b[38;5;{}m", 232 + shade.min(23))?; } - previous = Some(cell.shade); + previous = Some(cell.rgb); } write!(output, "{}", cell.glyph)?; } @@ -90,6 +92,25 @@ fn render_frame( Ok(()) } +// Encode before touching stdout: clearing the live screen before ray casting +// left a visible blank interval on every frame, especially on taller terminals. +fn render_frame( + output: &mut impl Write, + columns: u16, + rows: u16, + phase: f64, + truecolor: bool, + ansi256: bool, +) -> Result<()> { + let mut frame = Vec::new(); + frame.extend_from_slice(b"\x1b[?2026h"); + encode_frame(&mut frame, columns, rows, phase, truecolor, ansi256)?; + frame.extend_from_slice(b"\x1b[?2026l"); + output.write_all(&frame)?; + output.flush()?; + Ok(()) +} + pub(crate) fn play() -> Result<()> { let stdout = io::stdout(); let term = std::env::var("TERM").unwrap_or_default(); @@ -123,7 +144,7 @@ pub(crate) fn play() -> Result<()> { let phase = if reduced { 0.0 } else { - (started.elapsed().as_secs_f64() / 8.0).fract() + (started.elapsed().as_secs_f64() / hanzo::logo::LOOP_SECONDS).fract() }; render_frame(&mut output, cols, rows, phase, truecolor, ansi256)?; if reduced { @@ -151,6 +172,16 @@ pub(crate) fn play() -> Result<()> { mod tests { use super::*; + #[test] + fn no_color_output_retains_front_and_side_shading() -> Result<()> { + let mut output = Vec::new(); + render_frame(&mut output, 80, 40, 0.125, false, false)?; + let text = String::from_utf8(output)?; + assert!(text.contains('█') && text.contains('▒')); + assert!(!text.contains("\x1b[38;")); + Ok(()) + } + #[test] fn stage_never_overflows_80_columns() { assert_eq!(stage_size(80, 40), (60, 21)); diff --git a/npm/package.json b/npm/package.json index 1adf467c4d..dcbf41f999 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "@hanzo/dev", - "version": "0.7.4", + "version": "0.7.6", "license": "Apache-2.0", "description": "Local AI coding agent for your terminal, powered by Hanzo AI", "bin": { diff --git a/upstream/codex b/upstream/codex index c50d2217fc..c98891b5bd 160000 --- a/upstream/codex +++ b/upstream/codex @@ -1 +1 @@ -Subproject commit c50d2217fc5d97e155cf345f7ad6328fe7ea38c6 +Subproject commit c98891b5bd9902a5c1ad78b5f323a9032e4f0c6b