mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 02:26:02 -10:00
feat(tui): brighten ocean and animate working status
Update the default ocean palette and reuse the ratatui motion primitive for a restrained working sweep when terminal capabilities and motion preferences allow it. Parsed Mac PTY frames showed the sweep across 18 working samples and static idle and settled samples; physical-terminal appearance remains unqualified.
This commit is contained in:
1 parent
e7f2844049
commit
812f8c7c47
8 files changed
+267
-13
No files matched your search
@@ -1020,6 +1020,7 @@ impl Default for ComposerState {
|
||||
|
||||
/// Viewport/scroll state — fields related to transcript scrolling and caching.
|
||||
pub struct ViewportState {
|
||||
pub(crate) working_glow_visible: bool,
|
||||
/// Per-draw copy of the owning backend's negotiated capability facts.
|
||||
/// Unavailable outside a backend draw; never redetect or infer depth.
|
||||
pub(crate) ocean_caps: Option<codewhale_ratatui::Caps>,
|
||||
@@ -1095,6 +1096,7 @@ pub struct ViewportState {
|
||||
impl Default for ViewportState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
working_glow_visible: false,
|
||||
ocean_caps: None,
|
||||
ocean_semantic_surfaces: Vec::new(),
|
||||
transcript_scroll: TranscriptScroll::to_bottom(),
|
||||
|
||||
@@ -442,15 +442,28 @@ impl OceanRamp {
|
||||
return None;
|
||||
}
|
||||
|
||||
let default = *theme == codewhale_palette::UNDERWATER_UI_THEME;
|
||||
Some(Self {
|
||||
// The authored Codewhale water column: unmistakably blue all the
|
||||
// way to the floor. These restrained ocean shades sit between the
|
||||
// shell's ink surfaces and its ambient blue, so the field gains
|
||||
// depth without becoming a saturated blue panel.
|
||||
surface: NativeRamp::SURFACE,
|
||||
middle: NativeRamp::MIDDLE,
|
||||
surface: if default {
|
||||
Color::Rgb(0x0b, 0x2c, 0x53)
|
||||
} else {
|
||||
NativeRamp::SURFACE
|
||||
},
|
||||
middle: if default {
|
||||
Color::Rgb(0x09, 0x22, 0x41)
|
||||
} else {
|
||||
NativeRamp::MIDDLE
|
||||
},
|
||||
deep: NativeRamp::DEEP,
|
||||
ambient: NativeRamp::AMBIENT,
|
||||
ambient: if default {
|
||||
Color::Rgb(0x2a, 0x5e, 0x91)
|
||||
} else {
|
||||
NativeRamp::AMBIENT
|
||||
},
|
||||
attention: theme.warning,
|
||||
failure: theme.error_fg,
|
||||
})
|
||||
|
||||
@@ -34,8 +34,8 @@ fn contrast_ratio(foreground: Color, background: Color) -> f64 {
|
||||
fn whale_ramp_is_perceptibly_deep_not_merely_non_equal() {
|
||||
let ramp =
|
||||
OceanRamp::for_theme(&codewhale_palette::UNDERWATER_UI_THEME).expect("underwater ramp");
|
||||
assert_eq!(ramp.surface, Color::Rgb(0x10, 0x2a, 0x45));
|
||||
assert_eq!(ramp.middle, Color::Rgb(0x0a, 0x1e, 0x33));
|
||||
assert_eq!(ramp.surface, Color::Rgb(0x0b, 0x2c, 0x53));
|
||||
assert_eq!(ramp.middle, Color::Rgb(0x09, 0x22, 0x41));
|
||||
assert_eq!(ramp.deep, Color::Rgb(0x06, 0x13, 0x20));
|
||||
assert!(
|
||||
distance(ramp.surface, ramp.deep) >= 32,
|
||||
|
||||
@@ -686,6 +686,11 @@ pub struct TidelineFooter<'a> {
|
||||
/// the permission and mode chips, and the cap warning when it is owed —
|
||||
/// and nothing live. Width sheds the rest exactly as it always did.
|
||||
pub compact: bool,
|
||||
working_glow: Option<(
|
||||
codewhale_ratatui::Caps,
|
||||
std::time::Duration,
|
||||
codewhale_ratatui::MotionMode,
|
||||
)>,
|
||||
}
|
||||
|
||||
impl<'a> TidelineFooter<'a> {
|
||||
@@ -708,6 +713,7 @@ impl<'a> TidelineFooter<'a> {
|
||||
right: None,
|
||||
ascii_safe: false,
|
||||
compact: false,
|
||||
working_glow: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,6 +783,24 @@ impl<'a> TidelineFooter<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn working_glow(
|
||||
mut self,
|
||||
glow: Option<(
|
||||
codewhale_ratatui::Caps,
|
||||
std::time::Duration,
|
||||
codewhale_ratatui::MotionMode,
|
||||
)>,
|
||||
) -> Self {
|
||||
self.working_glow = glow;
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn working_clock_visible(&self, area: Rect) -> bool {
|
||||
self.kit()
|
||||
.turn_clock_hitbox(area, &crate::tui::infoline::source_theme(self.ascii_safe))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
fn kit(&self) -> PostureBar<'_> {
|
||||
use crate::tui::infoline::ink_role;
|
||||
// Preserve every host ink in a distinct role, including the right
|
||||
@@ -829,6 +853,24 @@ pub fn render_tideline_footer(
|
||||
let bar = footer.kit();
|
||||
let count_rects = bar.count_hitboxes(area, &source_theme(footer.ascii_safe));
|
||||
paint_native_row(&bar, area, buf, footer.theme, footer.ascii_safe);
|
||||
if let Some((mut caps, elapsed, motion)) = footer.working_glow
|
||||
&& let Some((label, _)) = footer.turn_clock
|
||||
&& let Some(clock_area) = bar.turn_clock_hitbox(area, &source_theme(footer.ascii_safe))
|
||||
&& let Some(appearance) =
|
||||
codewhale_ratatui::detect::appearance_for_background(footer.theme.footer_bg)
|
||||
{
|
||||
caps.appearance = appearance;
|
||||
let theme = codewhale_ratatui::Theme::new(caps);
|
||||
codewhale_ratatui::paint_working_glow(
|
||||
buf,
|
||||
clock_area,
|
||||
label,
|
||||
elapsed,
|
||||
codewhale_ratatui::MotionPolicy::new(motion, &theme),
|
||||
footer.theme.text_body,
|
||||
footer.theme.footer_bg,
|
||||
);
|
||||
}
|
||||
count_rects
|
||||
}
|
||||
|
||||
|
||||
@@ -524,6 +524,65 @@ fn render_info_row(
|
||||
.style(Style::default().bg(app.ui_theme.header_bg))
|
||||
.render(area, buf);
|
||||
ratatui::widgets::Widget::render(info, area, buf);
|
||||
if !identity_only
|
||||
&& !app.viewport.working_glow_visible
|
||||
&& app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Full
|
||||
&& !crate::tui::pet_watch::main_view(app)
|
||||
&& !app.onboarding_needs_api_key
|
||||
&& hovered != Some(InfoSegmentId::Model)
|
||||
&& !app.last_mouse_pos.is_some_and(|(x, y)| {
|
||||
route_area.is_some_and(|route| route.x <= x && x < route.right() && route.y == y)
|
||||
})
|
||||
&& app.motion_policy().should_request_animation_frames()
|
||||
&& matches!(
|
||||
crate::tui::underwater::ShellPhase::from_app(app),
|
||||
crate::tui::underwater::ShellPhase::Working
|
||||
)
|
||||
&& matches!(
|
||||
crate::tui::underwater::LiveActivity::from_app(app).kind(),
|
||||
crate::tui::underwater::LiveActivityKind::Working
|
||||
| crate::tui::underwater::LiveActivityKind::Reasoning
|
||||
)
|
||||
&& let Some((mut caps, started)) = app.viewport.ocean_caps.zip(app.turn_started_at)
|
||||
&& caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor
|
||||
&& let Some(appearance) =
|
||||
codewhale_ratatui::detect::appearance_for_background(app.ui_theme.header_bg)
|
||||
&& app.ui_theme.header_bg != app.ui_theme.surface_bg
|
||||
&& matches!(app.ui_theme.status_working, ratatui::style::Color::Rgb(..))
|
||||
&& let Some(model_area) = model_area
|
||||
&& let Some(model) = match route_fields.as_deref() {
|
||||
Some(fields) => fields
|
||||
.iter()
|
||||
.find(|field| field.kind == crate::tui::phase_strip::RouteFieldKind::Model)
|
||||
.map(|field| field.text.as_str()),
|
||||
None => segments
|
||||
.iter()
|
||||
.find(|segment| segment.id == InfoSegmentId::Model && segment.label.is_empty())
|
||||
.map(|segment| segment.value.as_str()),
|
||||
}
|
||||
{
|
||||
use unicode_width::UnicodeWidthStr as _;
|
||||
let width = model.width().min(usize::from(model_area.width));
|
||||
if width > 0 {
|
||||
caps.appearance = appearance;
|
||||
let theme = codewhale_ratatui::Theme::new(caps);
|
||||
let mode =
|
||||
codewhale_ratatui::MotionMode::from_settings(app.low_motion, app.fancy_animations);
|
||||
codewhale_ratatui::paint_working_glow(
|
||||
buf,
|
||||
Rect {
|
||||
width: width as u16,
|
||||
..model_area
|
||||
},
|
||||
model,
|
||||
started.elapsed(),
|
||||
codewhale_ratatui::MotionPolicy::new(mode, &theme),
|
||||
app.ui_theme.status_working,
|
||||
app.ui_theme.header_bg,
|
||||
);
|
||||
app.viewport.working_glow_visible = true;
|
||||
}
|
||||
}
|
||||
app.viewport.last_infoline_hitboxes = hitboxes;
|
||||
interaction_hitboxes
|
||||
}
|
||||
@@ -1562,6 +1621,7 @@ pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
|
||||
}
|
||||
|
||||
pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(u16, u16)> {
|
||||
app.viewport.working_glow_visible = false;
|
||||
app.viewport.ocean_semantic_surfaces.clear();
|
||||
let size = f.area();
|
||||
// Hover targets belong to the whole composed frame. Resetting inside the
|
||||
@@ -2041,12 +2101,40 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
facts.hint = None;
|
||||
}
|
||||
}
|
||||
let working_glow = if matches!(
|
||||
crate::tui::underwater::ShellPhase::from_app(app),
|
||||
crate::tui::underwater::ShellPhase::Working
|
||||
| crate::tui::underwater::ShellPhase::Verifying
|
||||
) {
|
||||
app.viewport
|
||||
.ocean_caps
|
||||
.zip(app.turn_started_at)
|
||||
.map(|(caps, started)| {
|
||||
(
|
||||
caps,
|
||||
started.elapsed(),
|
||||
codewhale_ratatui::MotionMode::from_settings(
|
||||
app.low_motion,
|
||||
app.fancy_animations,
|
||||
),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let footer = facts
|
||||
.widget(
|
||||
&app.ui_theme,
|
||||
crate::tui::color_compat::ascii_safe_enabled(),
|
||||
)
|
||||
.compact(app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Compact);
|
||||
.compact(app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Compact)
|
||||
.working_glow(working_glow);
|
||||
app.viewport.working_glow_visible = footer.working_clock_visible(area)
|
||||
&& working_glow.is_some_and(|(caps, _, mode)| {
|
||||
caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor && mode.animates()
|
||||
})
|
||||
&& matches!(app.ui_theme.footer_bg, ratatui::style::Color::Rgb(..))
|
||||
&& matches!(app.ui_theme.text_body, ratatui::style::Color::Rgb(..));
|
||||
let buf = f.buffer_mut();
|
||||
Block::default()
|
||||
.style(Style::default().bg(app.ui_theme.footer_bg))
|
||||
|
||||
@@ -155,6 +155,23 @@ pub(crate) fn rail_min_chat_width(idle_empty: bool) -> u16 {
|
||||
}
|
||||
}
|
||||
|
||||
fn working_clock_has_glow(app: &App) -> bool {
|
||||
app.motion_policy().should_request_animation_frames()
|
||||
&& app.viewport.working_glow_visible
|
||||
&& app.posture_bar == codewhale_runtime::config::ChromeRowPreset::Full
|
||||
&& !crate::tui::pet_watch::main_view(app)
|
||||
&& app.turn_started_at.is_some()
|
||||
&& app
|
||||
.viewport
|
||||
.ocean_caps
|
||||
.is_some_and(|caps| caps.depth == codewhale_ratatui::color::ColorDepth::TrueColor)
|
||||
&& matches!(
|
||||
crate::tui::underwater::ShellPhase::from_app(app),
|
||||
crate::tui::underwater::ShellPhase::Working
|
||||
| crate::tui::underwater::ShellPhase::Verifying
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
|
||||
if app.effective_low_motion_for_status() {
|
||||
crate::tui::display_refresh::adaptive_animation_interval_ms(true)
|
||||
@@ -162,6 +179,7 @@ pub(crate) fn status_animation_interval_ms(app: &App) -> u64 {
|
||||
|| !(app.is_loading
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
|| working_clock_has_glow(app)
|
||||
|| crate::tui::subagent_routing::running_agent_count(app) > 0)
|
||||
{
|
||||
UI_QUIET_STATUS_ANIMATION_MS
|
||||
@@ -249,6 +267,7 @@ pub(crate) fn should_tick_status_animation(
|
||||
) -> bool {
|
||||
!matches!(app.motion_policy().mode(), MotionMode::Still)
|
||||
&& (app.is_loading
|
||||
|| working_clock_has_glow(app)
|
||||
|| has_running_agents
|
||||
|| app.is_compacting
|
||||
|| app.is_purging
|
||||
|
||||
+68
-6
@@ -1,9 +1,5 @@
|
||||
//! Motion: the few things that move, and the promise that nothing else does.
|
||||
//!
|
||||
//! Motion is allowed for a change of state or for spatial continuity, and
|
||||
//! nothing else: no sweeps, dissolves, glitches or bounces. This module is
|
||||
//! that layer in a few hundred lines, instead of a dependency.
|
||||
//!
|
||||
//! - A [`MotionStep`] eases a value from where it is to a target in `0..=1`,
|
||||
//! sampled at an instant the caller passes in. Nothing here reads a clock,
|
||||
//! spawns a thread or waits.
|
||||
@@ -23,11 +19,16 @@
|
||||
use std::borrow::Cow;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::{buffer::Buffer, layout::Rect, style::Style};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{
|
||||
MotionMode, Paint, Role, Theme,
|
||||
color::{ColorDepth, blend},
|
||||
color::{ColorDepth, blend, contrast_ratio},
|
||||
glyphs, text, tokens,
|
||||
};
|
||||
|
||||
@@ -173,6 +174,67 @@ fn can_blend(theme: &Theme) -> bool {
|
||||
theme.paints_grounds() && theme.depth() == ColorDepth::TrueColor
|
||||
}
|
||||
|
||||
pub fn paint_working_glow(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
label: &str,
|
||||
elapsed: Duration,
|
||||
policy: MotionPolicy,
|
||||
highlight: Color,
|
||||
background: Color,
|
||||
) {
|
||||
if !policy.allows(MotionChannel::Tint)
|
||||
|| elapsed < crate::spin::EARN_DELAY
|
||||
|| !matches!(highlight, Color::Rgb(..))
|
||||
|| !matches!(background, Color::Rgb(..))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let area = area.intersection(buf.area);
|
||||
if area.is_empty() {
|
||||
return;
|
||||
}
|
||||
let label = text::display_safe(label);
|
||||
let width = text::width(&label).min(usize::from(area.width));
|
||||
if width == 0 {
|
||||
return;
|
||||
}
|
||||
let cycle = tokens::MOTION_LOOP_BREATHE_MS as u128;
|
||||
let phase = (elapsed - crate::spin::EARN_DELAY).as_millis() % cycle;
|
||||
let center = phase as f32 / cycle as f32 * (width as f32 + 4.0) - 2.0;
|
||||
let mut offset = 0usize;
|
||||
for grapheme in label.graphemes(true) {
|
||||
let cells = text::width(grapheme);
|
||||
if cells == 0 {
|
||||
continue;
|
||||
}
|
||||
if offset + cells > width {
|
||||
break;
|
||||
}
|
||||
let cell = &mut buf[(area.x + offset as u16, area.y)];
|
||||
let distance = (offset as f32 + (cells - 1) as f32 / 2.0 - center).abs();
|
||||
let strength = (1.0 - distance / 2.5).max(0.0) * 0.45;
|
||||
if strength > 0.0
|
||||
&& cell.symbol() == grapheme
|
||||
&& !grapheme.chars().all(char::is_whitespace)
|
||||
&& matches!(cell.fg, Color::Rgb(..))
|
||||
{
|
||||
let ground = match cell.bg {
|
||||
Color::Rgb(..) => cell.bg,
|
||||
_ => background,
|
||||
};
|
||||
let ink = blend(highlight, cell.fg, strength);
|
||||
if let (Some(before), Some(after)) =
|
||||
(contrast_ratio(cell.fg, ground), contrast_ratio(ink, ground))
|
||||
&& after >= before.min(4.5)
|
||||
{
|
||||
cell.fg = ink;
|
||||
}
|
||||
}
|
||||
offset += cells;
|
||||
}
|
||||
}
|
||||
|
||||
/// One value easing from where it is to a target, both in `0..=1`.
|
||||
///
|
||||
/// `0.0` is the start state (the `from` role, the home column, nothing
|
||||
|
||||
+29
-1
@@ -202,6 +202,7 @@ impl<'a> PostureBar<'a> {
|
||||
bold: true,
|
||||
joined: false,
|
||||
count_index: None,
|
||||
turn_clock: false,
|
||||
}];
|
||||
if let Some(mode) = self.mode.as_ref().filter(|_| shed < SHED_MODE) {
|
||||
items.push(PostureItem {
|
||||
@@ -216,6 +217,7 @@ impl<'a> PostureBar<'a> {
|
||||
bold: false,
|
||||
joined: false,
|
||||
count_index: None,
|
||||
turn_clock: false,
|
||||
});
|
||||
}
|
||||
// Preserve the native turn-only clock rung: removing the session
|
||||
@@ -226,7 +228,9 @@ impl<'a> PostureBar<'a> {
|
||||
SHED_TURN_CLOCK
|
||||
};
|
||||
if let Some(clock) = self.turn_clock.as_ref().filter(|_| shed < turn_shed) {
|
||||
items.push(PostureItem::fact(clock, ascii));
|
||||
let mut item = PostureItem::fact(clock, ascii);
|
||||
item.turn_clock = true;
|
||||
items.push(item);
|
||||
}
|
||||
if shed < SHED_COUNTS {
|
||||
for (index, count) in self.counts.iter().enumerate() {
|
||||
@@ -243,6 +247,7 @@ impl<'a> PostureBar<'a> {
|
||||
bold: false,
|
||||
joined: index > 0,
|
||||
count_index: Some(index),
|
||||
turn_clock: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -312,6 +317,11 @@ impl<'a> PostureBar<'a> {
|
||||
};
|
||||
layout.count_hitboxes(theme.ascii())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn turn_clock_hitbox(&self, area: Rect, theme: &Theme) -> Option<Rect> {
|
||||
self.layout(area, theme)?.turn_clock_hitbox(theme.ascii())
|
||||
}
|
||||
}
|
||||
|
||||
struct PostureItem {
|
||||
@@ -322,6 +332,7 @@ struct PostureItem {
|
||||
bold: bool,
|
||||
joined: bool,
|
||||
count_index: Option<usize>,
|
||||
turn_clock: bool,
|
||||
}
|
||||
|
||||
impl PostureItem {
|
||||
@@ -334,6 +345,7 @@ impl PostureItem {
|
||||
bold: false,
|
||||
joined: false,
|
||||
count_index: None,
|
||||
turn_clock: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +412,22 @@ impl PostureLayout {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn turn_clock_hitbox(&self, ascii: bool) -> Option<Rect> {
|
||||
let mut x = usize::from(self.area.x);
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
x += text::width(&project(item.separator(), ascii));
|
||||
}
|
||||
if item.turn_clock {
|
||||
let width = text::width(&self.clip(x, &item.text));
|
||||
return (width > 0 && x <= usize::from(u16::MAX))
|
||||
.then(|| Rect::new(x as u16, self.area.y, width as u16, 1));
|
||||
}
|
||||
x += item.width();
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Paint for PostureBar<'_> {
|
||||
|
||||
Reference in new issue
Block a user