fix(editor): restore chat toolbar chrome polish

- run the divider above the input across the full panel width
- paint the built-in model key with the real lucide Key glyph instead
  of a hand-rolled ring+shaft approximation
- concurrency chip rests as a faint ghost at 1x and gets a
  primary-tinted chip when the agent team is staffed, keeping the
  footer hover + cycle wiring
- attach/send are ghost icons; send fades to muted-foreground/30 when
  there is nothing to send

Re-lands 94b9aa1b (dropped during a re-batch) merged with the
agent-team footer layout.
This commit is contained in:
Fini 2026-06-10 21:02:21 +08:00
parent 09e1f5f606
commit 79f2a13ae1
4 changed files with 76 additions and 61 deletions

View file

@ -568,24 +568,15 @@ fn with_alpha(color: Color, a: f32) -> Color {
}
pub(crate) fn paint_key_glyph(cx: &mut PaintCx<'_>, top_left: Point2D, size: f32, color: Color) {
let cy = top_left.y + size * 0.5;
let ring = Rect {
origin: Point2D::new(top_left.x, cy - size * 0.28),
size: Point2D::new(size * 0.55, size * 0.55),
};
cx.backend
.stroke_round_rect(ring, ring.size.x / 2.0, color, 1.3);
cx.backend.stroke_line(
Point2D::new(top_left.x + size * 0.52, cy),
Point2D::new(top_left.x + size, cy),
// Use the real lucide `Key` glyph (TS renders `<Key/>`) rather than a
// hand-rolled ring+shaft approximation.
crate::widgets::icons::draw_icon(
cx.backend,
crate::widgets::icons::Icon::Key,
top_left,
size,
color,
1.3,
);
cx.backend.stroke_line(
Point2D::new(top_left.x + size * 0.82, cy),
Point2D::new(top_left.x + size * 0.82, cy + size * 0.25),
color,
1.3,
1.4,
);
}

View file

@ -555,19 +555,50 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
1.4,
);
model_x += 18.0;
// Concurrency chip — `Zap` + `{n}x`. A solo 1x team rests as a
// faint ghost; a staffed team (>1) gets a primary-tinted chip so
// the parallel mode reads as active.
let chip = footer.agent_team;
cx.backend.fill_round_rect(chip, 6.0, self.theme.muted);
if self.footer_hover == Some(ChatFooterButton::AgentTeam) {
cx.backend
.fill_round_rect(chip, 6.0, chat_neutral_hover_color(&self.theme));
let team_size = self.state.agent_team_size;
let team_active = team_size > 1;
let conc_color = if team_active {
self.theme.primary
} else {
Color {
a: 0.5,
..self.theme.muted_foreground
}
};
let primary_wash = Color {
a: 0.1,
..self.theme.primary
};
if team_active {
cx.backend.fill_round_rect(chip, 6.0, primary_wash);
}
let team_label = format!("{}x", self.state.agent_team_size);
if self.footer_hover == Some(ChatFooterButton::AgentTeam) {
let wash = if team_active {
primary_wash
} else {
chat_neutral_hover_color(&self.theme)
};
cx.backend.fill_round_rect(chip, 6.0, wash);
}
draw_icon(
cx.backend,
Icon::Zap,
Point2D::new(chip.origin.x + 4.0, chip.origin.y + 5.0),
10.0,
conc_color,
1.4,
);
let team_label = format!("{}x", team_size);
draw_label(
cx,
&team_label,
11.0,
self.theme.muted_foreground,
chip.origin.x + 7.0,
conc_color,
chip.origin.x + 16.0,
chip.origin.y + 14.0,
);
model_x += 36.0;
@ -583,9 +614,9 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
toolbar_center_y + 4.0,
);
// Right cluster — attach + send (TS ghost buttons: bare icons
// that only get a wash while hovered).
let attach_rect = footer.attach;
cx.backend
.fill_round_rect(attach_rect, 6.0, self.theme.muted);
if self.footer_hover == Some(ChatFooterButton::AddAttachment) {
cx.backend
.fill_round_rect(attach_rect, 6.0, chat_neutral_hover_color(&self.theme));
@ -604,26 +635,26 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
let send_active = can_use_model
&& (!self.state.input.trim().is_empty() || !self.state.pending_attachments.is_empty());
let streaming = self.is_streaming();
let (send_bg, icon_color, send_icon) = if streaming {
(
self.theme.destructive,
self.theme.primary_foreground,
Icon::Square,
)
// TS: streaming → red stop; sendable → primary; otherwise a faded
// muted-foreground/30 icon so the send button reads as disabled.
let (icon_color, send_icon) = if streaming {
(self.theme.destructive, Icon::Square)
} else if send_active {
(self.theme.primary, Icon::Send)
} else {
(
self.theme.primary,
self.theme.primary_foreground,
Color {
a: 0.3,
..self.theme.muted_foreground
},
Icon::Send,
)
} else {
(self.theme.muted, self.theme.muted_foreground, Icon::Send)
};
cx.backend.fill_round_rect(send_rect, 6.0, send_bg);
if self.footer_hover == Some(ChatFooterButton::Send)
|| self.footer_hover == Some(ChatFooterButton::Stop)
{
paint_footer_hover_overlay(cx, send_rect, send_active || streaming, &self.theme);
cx.backend
.fill_round_rect(send_rect, 6.0, chat_neutral_hover_color(&self.theme));
}
draw_icon(
cx.backend,
@ -674,20 +705,6 @@ fn draw_label(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32,
cx.backend.draw_text(&label, Point2D::new(x, y));
}
fn paint_footer_hover_overlay(cx: &mut PaintCx<'_>, rect: Rect, bright: bool, theme: &Theme) {
let color = if bright {
Color {
r: 1.0,
g: 1.0,
b: 1.0,
a: 0.14,
}
} else {
chat_neutral_hover_color(theme)
};
cx.backend.fill_round_rect(rect, 6.0, color);
}
fn chat_neutral_hover_color(theme: &Theme) -> Color {
Color {
r: theme.foreground.r,

View file

@ -371,9 +371,11 @@ fn paint_send_button_hover_adds_visible_feedback() {
.filter(|(r, _, _)| rect_close(*r, send_rect))
.collect();
// Ghost button: no resting fill, so hover feedback is the only
// wash painted over the send rect.
assert!(
fills.len() >= 2,
"hovered send button should paint feedback over its base fill"
!fills.is_empty(),
"hovered send button should paint a hover wash"
);
}
@ -839,6 +841,7 @@ struct PanelPaintBackend {
round_rects: Vec<(Rect, f32, crate::Color)>,
texts: Vec<(String, f32, jian_core::scene::Color, Point2D)>,
svg_strokes: Vec<(Point2D, f32, crate::Color, f32)>,
svg_paths: Vec<String>,
stroke_lines: usize,
}
@ -868,12 +871,13 @@ impl crate::RenderBackend for PanelPaintBackend {
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: crate::Color, _: f32) {}
fn stroke_svg_path(
&mut self,
_: &str,
d: &str,
top_left: Point2D,
size: f32,
color: crate::Color,
width: f32,
) {
self.svg_paths.push(d.to_string());
self.svg_strokes.push((top_left, size, color, width));
}
fn resize(&mut self, _: u32, _: u32) {}
@ -903,9 +907,12 @@ fn paint_model_chip_uses_key_glyph_for_builtin_model() {
panel.paint(&mut cx, rect);
let key_paths = crate::widgets::icons::Icon::Key.paths();
assert!(
backend.stroke_lines >= 2,
"built-in selected model chip should paint the TS-style Key glyph"
key_paths
.iter()
.all(|kp| backend.svg_paths.iter().any(|p| p == kp)),
"built-in selected model chip should paint the lucide Key glyph"
);
}

View file

@ -119,10 +119,10 @@ pub(crate) fn paint_panel_body_chrome(cx: &mut PaintCx<'_>, theme: &Theme, rect:
with_alpha(theme.background, 0.8),
);
}
cx.backend.fill_rect(
Rect::xywh(rect.origin.x + PAD, sep_y, rect.size.x - PAD * 2.0, 1.0),
theme.border,
);
// Full-width divider above the input (TS `border-t` spans the whole
// panel, not just the padded content column).
cx.backend
.fill_rect(Rect::xywh(inner_x, sep_y, inner_w, 1.0), theme.border);
}
/// Paint the empty-state hint line + the 2×2 example-card grid.