diff --git a/crates/op-editor-ui/src/widgets/color_picker.rs b/crates/op-editor-ui/src/widgets/color_picker.rs index f5fbff0ff..8c0a77b2f 100644 --- a/crates/op-editor-ui/src/widgets/color_picker.rs +++ b/crates/op-editor-ui/src/widgets/color_picker.rs @@ -1,42 +1,23 @@ -//! Floating HSV colour picker — opens from a Fill / Stroke swatch -//! click in the PropertyPanel. SV box + hue strip + R/G/B readout -//! + hex; mirrors the layout of common design-tool pickers. +//! Floating HSV colour picker — opens from a Fill / Stroke swatch click in +//! the PropertyPanel. The rendering, layout, hit-test and HSV↔RGB math live +//! in jian `components::color_picker`; this wrapper owns the +//! property-panel-relative anchor, the `EditorState` plumbing, and the +//! drag-dispatch glue. use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; -use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::icons::Icon; use crate::widgets::{PaintCx, Widget, WidgetId}; -use crate::{Color, Point2D, Rect, TextLayout}; +use crate::{Point2D, Rect}; +use jian_widgets::components::color_picker::ColorPicker as JianColorPicker; use op_editor_core::ui_draft::{ColorPickerDrag, ColorPickerState, ColorTarget}; use op_editor_core::EditorState; -pub const PICKER_WIDTH: f32 = 240.0; -pub const PICKER_HEIGHT: f32 = 336.0; - -const PAD: f32 = 8.0; -const HEADER_HEIGHT: f32 = 28.0; -const SV_HEIGHT: f32 = 140.0; -const ROW_GAP: f32 = 8.0; -const HUE_HEIGHT: f32 = 12.0; -const HUE_HANDLE_R: f32 = 7.0; -const SV_HANDLE_R: f32 = 6.0; -const SWATCH_SIZE: f32 = 28.0; -const RGB_BOX_W: f32 = 56.0; -const RGB_BOX_H: f32 = 32.0; -const HEX_HEIGHT: f32 = 28.0; - -/// What a press inside the picker did. The host translates these -/// to a `ColorPickerDrag` it then keeps live until release. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ColorPickerHit { - SvBox, - HueSlider, - Eyedropper, - Close, - /// Click inside the panel that doesn't hit a control — swallows - /// to keep the picker open. - Inside, -} +// Re-export jian's public surface so hosts (op-host-native / op-host-web) and +// sibling widgets keep importing it from this module unchanged. +pub use jian_widgets::components::color_picker::{ + hsv_to_rgb, rgb_to_hsv, ColorPickerHit, PICKER_HEIGHT, PICKER_WIDTH, +}; pub struct ColorPicker { pub id: WidgetId, @@ -56,10 +37,10 @@ impl ColorPicker { } } - /// Resolve the picker's anchor (top-left) in viewport coords — - /// pinned near an explicit click point when available, otherwise - /// to the right rail near the Fill section. Hosts use this for - /// paint AND hit-test so they stay in sync. + /// Resolve the picker's anchor (top-left) in viewport coords — pinned near + /// an explicit click point when available, otherwise to the right rail near + /// the Fill section. Hosts use this for paint AND hit-test so they stay in + /// sync. (Domain-specific to OP's right rail, so it stays here.) pub fn rect(&self, viewport_w: f32, viewport_h: f32) -> Rect { let x = self .state @@ -69,8 +50,6 @@ impl ColorPicker { let right_rail_left = viewport_w - self.property_panel_width; (right_rail_left - PICKER_WIDTH - 8.0).max(8.0) }); - // Center vertically around the swatch the user clicked, then - // clamp so the panel stays fully on-screen. let mut y = self.state.anchor_y - PICKER_HEIGHT / 2.0; let top_min = crate::widgets::TOP_BAR_HEIGHT + 8.0; let bottom_max = (viewport_h - PICKER_HEIGHT - 8.0).max(top_min); @@ -86,47 +65,19 @@ impl ColorPicker { } } - /// Map a press point inside the picker to a hit (or None if the - /// point is outside the panel rect entirely). + /// Map a press point to a hit, or `None` if outside the panel. pub fn hit_test(&self, panel: Rect, point: Point2D) -> Option { - if !(panel).contains(point) { - return None; - } - // Close chip sits visually on top of the SV box's top-right - // corner — check it first so its presses don't get swallowed - // by the SV drag handler. - let close = close_rect(panel); - if (close).contains(point) { - return Some(ColorPickerHit::Close); - } - let sv = sv_rect(panel); - if (sv).contains(point) { - return Some(ColorPickerHit::SvBox); - } - let hue = hue_rect(panel); - if (hue).contains(point) { - return Some(ColorPickerHit::HueSlider); - } - let eye = eyedropper_rect(panel); - if (eye).contains(point) { - return Some(ColorPickerHit::Eyedropper); - } - Some(ColorPickerHit::Inside) + JianColorPicker::hit(panel, point) } - /// Compute new HSV when dragging inside the SV box. + /// New `(sat, val)` when dragging inside the SV box. pub fn sv_at(&self, panel: Rect, point: Point2D) -> (f32, f32) { - let sv = sv_rect(panel); - let x = ((point.x - sv.origin.x) / sv.size.x).clamp(0.0, 1.0); - let y = ((point.y - sv.origin.y) / sv.size.y).clamp(0.0, 1.0); - (x, 1.0 - y) + JianColorPicker::sv_at(panel, point) } - /// Compute new hue when dragging across the hue strip. + /// New hue when dragging across the hue strip. pub fn hue_at(&self, panel: Rect, point: Point2D) -> f32 { - let h = hue_rect(panel); - let t = ((point.x - h.origin.x) / h.size.x).clamp(0.0, 1.0); - t * 360.0 + JianColorPicker::hue_at(panel, point) } } @@ -157,7 +108,19 @@ impl Widget for ColorPicker { } fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) { - paint_picker(cx, &self.theme, &self.state, rect); + JianColorPicker { + hue: self.state.hue, + sat: self.state.sat, + val: self.state.val, + title: target_label(self.state.target), + eyedropper_icon: Icon::Pencil.paths(), + close_icon: Icon::Close.paths(), + } + .paint( + cx.backend, + rect, + &crate::widgets::button::tokens_from_theme(&self.theme), + ); } fn access_node(&self) -> accesskit::Node { @@ -167,337 +130,7 @@ impl Widget for ColorPicker { } } -fn paint_picker(cx: &mut PaintCx<'_>, theme: &Theme, state: &ColorPickerState, panel: Rect) { - // Card background + outline. - cx.backend.fill_round_rect(panel, 8.0, theme.card); - cx.backend.stroke_round_rect(panel, 8.0, theme.border, 1.0); - - // 0) Header row — picker title + close chip. - let title = match state.target { - ColorTarget::Fill => "Fill", - ColorTarget::Stroke => "Stroke", - ColorTarget::GradientStop(_) => "Gradient Stop", - ColorTarget::EffectColor(_) => "Effect Color", - }; - let title_layout = TextLayout::single_run( - title, - "system-ui", - 13.0, - (theme.foreground).to_jian(), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &title_layout, - Point2D::new(panel.origin.x + PAD, header_top(panel) + 19.0), - ); - - // 1) SV box — saturation × value gradient under the current hue. - // Software-painted with a column-by-column gradient, then a - // vertical black overlay. Quick + faithful enough. - paint_sv_box(cx, sv_rect(panel), state.hue, state.sat, state.val); - - // 2) Hue strip — 6-segment rainbow with a circular handle. - paint_hue_strip(cx, hue_rect(panel), state.hue, theme); - - // 3) Eyedropper + current swatch. - let eye = eyedropper_rect(panel); - draw_icon( - cx.backend, - Icon::Pencil, - Point2D::new(eye.origin.x, eye.origin.y + 4.0), - 16.0, - theme.muted_foreground, - 1.5, - ); - let swatch = swatch_rect(panel); - let cur = hsv_to_rgb(state.hue, state.sat, state.val); - cx.backend.fill_round_rect(swatch, 14.0, cur); - cx.backend - .stroke_round_rect(swatch, 14.0, theme.border, 1.0); - - // 4) R/G/B numeric boxes. - let (r, g, b) = ( - (cur.r * 255.0).round() as u8, - (cur.g * 255.0).round() as u8, - (cur.b * 255.0).round() as u8, - ); - let rgb_y = sv_rect(panel).origin.y + SV_HEIGHT + ROW_GAP + 36.0 + ROW_GAP; - for (i, (label, value)) in [("R", r), ("G", g), ("B", b)].iter().enumerate() { - let value = *value; - let label = *label; - let bx = panel.origin.x + PAD + (i as f32) * (RGB_BOX_W + 8.0); - let box_rect = Rect { - origin: Point2D::new(bx, rgb_y), - size: Point2D::new(RGB_BOX_W, RGB_BOX_H), - }; - cx.backend - .stroke_round_rect(box_rect, 6.0, theme.border, 1.0); - let val_str = value.to_string(); - let text_w = cx.backend.measure_text(&val_str, 14.0); - let text = TextLayout::single_run( - &val_str, - "system-ui", - 14.0, - (theme.foreground).to_jian(), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &text, - Point2D::new(bx + (RGB_BOX_W - text_w) / 2.0, rgb_y + 21.0), - ); - let lab_w = cx.backend.measure_text(label, 12.0); - let lab = TextLayout::single_run( - label, - "system-ui", - 12.0, - (theme.muted_foreground).to_jian(), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &lab, - Point2D::new(bx + (RGB_BOX_W - lab_w) / 2.0, rgb_y + RGB_BOX_H + 14.0), - ); - } - - // 5) Hex bar at the bottom. - let hex_rect = Rect { - origin: Point2D::new( - panel.origin.x + PAD, - panel.origin.y + PICKER_HEIGHT - PAD - HEX_HEIGHT, - ), - size: Point2D::new(PICKER_WIDTH - PAD * 2.0, HEX_HEIGHT), - }; - cx.backend.fill_round_rect(hex_rect, 6.0, theme.muted); - cx.backend - .stroke_round_rect(hex_rect, 6.0, theme.primary, 1.0); - let mini = Rect { - origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 6.0), - size: Point2D::new(16.0, 16.0), - }; - cx.backend.fill_round_rect(mini, 3.0, cur); - cx.backend.stroke_round_rect(mini, 3.0, theme.border, 1.0); - let hex_str = format!("#{:02x}{:02x}{:02x}", r, g, b); - let hex_layout = TextLayout::single_run( - &hex_str, - "system-ui", - 13.0, - (theme.foreground).to_jian(), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &hex_layout, - Point2D::new(hex_rect.origin.x + 32.0, hex_rect.origin.y + 19.0), - ); - - // 6) Close (×) chip — filled hover-card so it actually reads - // against the SV gradient behind it. - let close = close_rect(panel); - let chip_bg = Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.45, - }; - cx.backend.fill_round_rect(close, 11.0, chip_bg); - cx.backend.stroke_round_rect(close, 11.0, theme.border, 1.0); - draw_icon( - cx.backend, - Icon::Close, - Point2D::new(close.origin.x + 4.0, close.origin.y + 4.0), - 14.0, - Color::WHITE, - 1.8, - ); - - // SV handle and hue handle on top of everything. - paint_sv_handle(cx, sv_rect(panel), state, theme); - paint_hue_handle(cx, hue_rect(panel), state.hue, theme); -} - -fn paint_sv_box(cx: &mut PaintCx<'_>, sv: Rect, hue: f32, _sat: f32, _val: f32) { - // Column gradient: at value=1, the row interpolates from white - // (sat=0) to the hue's pure colour (sat=1); a vertical overlay - // darkens to black as value drops to 0. Approximate with N=18 - // column strips for cheap painting. - const STEPS_X: u32 = 18; - const STEPS_Y: u32 = 14; - let pure = hsv_to_rgb(hue, 1.0, 1.0); - let cell_w = sv.size.x / STEPS_X as f32; - let cell_h = sv.size.y / STEPS_Y as f32; - for ix in 0..STEPS_X { - for iy in 0..STEPS_Y { - let s = (ix as f32 + 0.5) / STEPS_X as f32; - let v = 1.0 - (iy as f32 + 0.5) / STEPS_Y as f32; - let r = (1.0 - s) * 1.0 + s * pure.r; - let g = (1.0 - s) * 1.0 + s * pure.g; - let b = (1.0 - s) * 1.0 + s * pure.b; - let color = Color { - r: r * v, - g: g * v, - b: b * v, - a: 1.0, - }; - let cell = Rect { - origin: Point2D::new( - sv.origin.x + ix as f32 * cell_w, - sv.origin.y + iy as f32 * cell_h, - ), - size: Point2D::new(cell_w + 0.5, cell_h + 0.5), - }; - cx.backend.fill_rect(cell, color); - } - } - cx.backend.stroke_round_rect( - sv, - 4.0, - Color { - r: 0.0, - g: 0.0, - b: 0.0, - a: 0.25, - }, - 1.0, - ); -} - -fn paint_hue_strip(cx: &mut PaintCx<'_>, hue: Rect, _current: f32, _theme: &Theme) { - const STEPS: u32 = 32; - let cell_w = hue.size.x / STEPS as f32; - for i in 0..STEPS { - let h = (i as f32 + 0.5) / STEPS as f32 * 360.0; - let c = hsv_to_rgb(h, 1.0, 1.0); - let cell = Rect { - origin: Point2D::new(hue.origin.x + i as f32 * cell_w, hue.origin.y), - size: Point2D::new(cell_w + 0.5, hue.size.y), - }; - cx.backend.fill_round_rect(cell, hue.size.y / 2.0, c); - } -} - -fn paint_sv_handle(cx: &mut PaintCx<'_>, sv: Rect, state: &ColorPickerState, theme: &Theme) { - let x = sv.origin.x + state.sat * sv.size.x; - let y = sv.origin.y + (1.0 - state.val) * sv.size.y; - let ring = Rect { - origin: Point2D::new(x - SV_HANDLE_R, y - SV_HANDLE_R), - size: Point2D::new(SV_HANDLE_R * 2.0, SV_HANDLE_R * 2.0), - }; - cx.backend.stroke_oval(ring, theme.background, 3.0); - cx.backend.stroke_oval(ring, Color::WHITE, 1.5); -} - -fn paint_hue_handle(cx: &mut PaintCx<'_>, hue: Rect, current_hue: f32, theme: &Theme) { - let cx0 = hue.origin.x + (current_hue / 360.0).clamp(0.0, 1.0) * hue.size.x; - let cy0 = hue.origin.y + hue.size.y / 2.0; - let ring = Rect { - origin: Point2D::new(cx0 - HUE_HANDLE_R, cy0 - HUE_HANDLE_R), - size: Point2D::new(HUE_HANDLE_R * 2.0, HUE_HANDLE_R * 2.0), - }; - let center = Rect { - origin: Point2D::new(cx0 - 5.0, cy0 - 5.0), - size: Point2D::new(10.0, 10.0), - }; - cx.backend - .fill_oval(center, hsv_to_rgb(current_hue, 1.0, 1.0)); - cx.backend.stroke_oval(ring, theme.background, 1.0); - cx.backend.stroke_oval(ring, Color::WHITE, 2.0); -} - -fn header_top(panel: Rect) -> f32 { - panel.origin.y + PAD -} - -fn sv_rect(panel: Rect) -> Rect { - Rect { - origin: Point2D::new(panel.origin.x + PAD, header_top(panel) + HEADER_HEIGHT), - size: Point2D::new(PICKER_WIDTH - PAD * 2.0, SV_HEIGHT), - } -} - -fn hue_rect(panel: Rect) -> Rect { - let y = sv_rect(panel).origin.y + SV_HEIGHT + ROW_GAP + 8.0; - let x = panel.origin.x + PAD + SWATCH_SIZE + 24.0 + 8.0; - let w = PICKER_WIDTH - PAD * 2.0 - SWATCH_SIZE - 24.0 - 8.0; - Rect { - origin: Point2D::new(x, y), - size: Point2D::new(w, HUE_HEIGHT), - } -} - -fn eyedropper_rect(panel: Rect) -> Rect { - let y = sv_rect(panel).origin.y + SV_HEIGHT + ROW_GAP + 6.0; - Rect { - origin: Point2D::new(panel.origin.x + PAD, y), - size: Point2D::new(20.0, 20.0), - } -} - -fn swatch_rect(panel: Rect) -> Rect { - let y = sv_rect(panel).origin.y + SV_HEIGHT + ROW_GAP; - Rect { - origin: Point2D::new(panel.origin.x + PAD + 24.0, y), - size: Point2D::new(SWATCH_SIZE, SWATCH_SIZE), - } -} - -/// Close chip — pinned to the header row above the SV box so it -/// doesn't steal clicks meant for the colour gradient. -fn close_rect(panel: Rect) -> Rect { - Rect { - origin: Point2D::new( - panel.origin.x + PICKER_WIDTH - PAD - 22.0, - header_top(panel) + (HEADER_HEIGHT - 22.0) / 2.0, - ), - size: Point2D::new(22.0, 22.0), - } -} - -/// HSV → RGB, h 0..360, s/v 0..1. Returns alpha 1. -pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Color { - let h = h.rem_euclid(360.0); - let c = v * s; - let hh = h / 60.0; - let x = c * (1.0 - (hh.rem_euclid(2.0) - 1.0).abs()); - let (r1, g1, b1) = match hh as u32 { - 0 => (c, x, 0.0), - 1 => (x, c, 0.0), - 2 => (0.0, c, x), - 3 => (0.0, x, c), - 4 => (x, 0.0, c), - _ => (c, 0.0, x), - }; - let m = v - c; - Color { - r: r1 + m, - g: g1 + m, - b: b1 + m, - a: 1.0, - } -} - -/// RGB (0..1) → HSV (h 0..360, s 0..1, v 0..1). -pub fn rgb_to_hsv(c: Color) -> (f32, f32, f32) { - let (r, g, b) = (c.r, c.g, c.b); - let max = r.max(g).max(b); - let min = r.min(g).min(b); - let v = max; - let delta = max - min; - let s = if max <= 0.0 { 0.0 } else { delta / max }; - let h = if delta == 0.0 { - 0.0 - } else if max == r { - 60.0 * (((g - b) / delta) % 6.0) - } else if max == g { - 60.0 * (((b - r) / delta) + 2.0) - } else { - 60.0 * (((r - g) / delta) + 4.0) - }; - let h = if h < 0.0 { h + 360.0 } else { h }; - (h, s, v) -} - -/// Match the host's `apply_cursor_move` drag dispatch with the -/// document's stored kind — convenience the host calls when -/// translating a press into the persistent drag state. +/// Translate a press hit into the persistent drag kind the host keeps live. pub fn drag_for_hit(hit: ColorPickerHit) -> Option { match hit { ColorPickerHit::SvBox => Some(ColorPickerDrag::SvBox), diff --git a/vendor/jian b/vendor/jian index f0fbf70d6..958024771 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit f0fbf70d65a6161e47dccf19e208049c6597e307 +Subproject commit 9580247719eb4f89beaf6a1bbb1f67a9de9592a2