diff --git a/crates/op-editor-core/src/color_picker.rs b/crates/op-editor-core/src/color_picker.rs index 2694d0017..17b1b3121 100644 --- a/crates/op-editor-core/src/color_picker.rs +++ b/crates/op-editor-core/src/color_picker.rs @@ -145,6 +145,8 @@ impl EditorState { alpha, hex_focused: false, hex_input: jian_core::text_input::TextInputState::default(), + rgb_focus: None, + rgb_input: jian_core::text_input::TextInputState::default(), }); true } @@ -246,6 +248,8 @@ impl EditorState { alpha: 1.0, hex_focused: false, hex_input: jian_core::text_input::TextInputState::default(), + rgb_focus: None, + rgb_input: jian_core::text_input::TextInputState::default(), }); true } @@ -326,89 +330,6 @@ impl EditorState { } } - /// Whether the picker's hex field currently has keyboard focus. - pub fn color_picker_hex_focused(&self) -> bool { - self.ui.color_picker.as_ref().is_some_and(|s| s.hex_focused) - } - - /// Focus the hex field, seeding the unified `hex_input` from the current - /// colour (caret at end) so the user edits the live `#RRGGBB`. - pub fn color_picker_focus_hex(&mut self) { - if let Some(state) = self.ui.color_picker.as_mut() { - let (r, g, b) = hsv_to_rgb(state.hue, state.sat, state.val); - state.hex_input = jian_core::text_input::TextInputState::with_text(rgb_to_hex(r, g, b)); - state.hex_focused = true; - } - } - - /// Insert one hex character (or a leading `#`) at the caret, capped at - /// `#RRGGBB`, then live-apply when the draft is a complete colour. Mirrors - /// the property-panel hex input's gating (`keyboard.rs` `is_hex_focus`). - pub fn color_picker_hex_char(&mut self, ch: char, now_ms: u64) { - let Some(state) = self.ui.color_picker.as_mut() else { - return; - }; - if !state.hex_focused { - return; - } - let input = &state.hex_input; - let replacing_all = input.is_select_all(); - let draft = input.text(); - let pos = if replacing_all { - 0 - } else { - input.caret().min(draft.len()) - }; - let allowed = (replacing_all || draft.len() < 7) - && (ch.is_ascii_hexdigit() || (ch == '#' && pos == 0 && !draft.starts_with('#'))); - if !allowed { - return; - } - let mut buf = [0u8; 4]; - let lower = ch.to_ascii_lowercase(); - state.hex_input.insert_str(lower.encode_utf8(&mut buf), now_ms); - self.color_picker_apply_hex_draft(); - } - - /// Delete the character before the caret. - pub fn color_picker_hex_backspace(&mut self, now_ms: u64) { - let Some(state) = self.ui.color_picker.as_mut() else { - return; - }; - if !state.hex_focused { - return; - } - state.hex_input.backspace(now_ms); - self.color_picker_apply_hex_draft(); - } - - /// Commit + blur the hex field. No-op when the hex field isn't focused (so - /// callers can blur unconditionally without re-applying a stale draft). - pub fn color_picker_blur_hex(&mut self) { - if !self.color_picker_hex_focused() { - return; - } - self.color_picker_apply_hex_draft(); - if let Some(state) = self.ui.color_picker.as_mut() { - state.hex_focused = false; - } - } - - /// Parse the hex draft and, when it is a valid colour, route it through the - /// normal HSV commit so fill / stroke / variable targets all update. - fn color_picker_apply_hex_draft(&mut self) { - let draft = self - .ui - .color_picker - .as_ref() - .map(|s| s.hex_input.text().to_owned()) - .unwrap_or_default(); - if let Some((r, g, b)) = parse_hex_rgb(&draft) { - let (h, s, v) = rgb_to_hsv((r, g, b)); - self.color_picker_set_hsv(h, s, v); - } - } - /// Close the picker. Pushes the pre-edit snapshot onto the undo /// stack when the colour actually changed; drops it otherwise. /// Returns true when a picker was open. diff --git a/crates/op-editor-core/src/color_picker_edit.rs b/crates/op-editor-core/src/color_picker_edit.rs new file mode 100644 index 000000000..48bfb56d5 --- /dev/null +++ b/crates/op-editor-core/src/color_picker_edit.rs @@ -0,0 +1,191 @@ +//! Keyboard-edit state machines for the colour picker's hex bar and R/G/B +//! numeric boxes — extracted from `color_picker.rs` to keep it under the +//! 800-line cap. Both fields render through jian `TextInputView`; the host +//! routes keystrokes here (`color_picker_hex_*` / `color_picker_rgb_*`) and +//! each live-applies through the shared `color_picker_set_hsv` commit so +//! fill / stroke / variable targets all update. + +use crate::color_convert::{hsv_to_rgb, parse_hex_rgb, rgb_to_hex, rgb_to_hsv}; +use crate::state::EditorState; + +impl EditorState { + /// Whether the picker's hex field currently has keyboard focus. + pub fn color_picker_hex_focused(&self) -> bool { + self.ui.color_picker.as_ref().is_some_and(|s| s.hex_focused) + } + + /// Focus the hex field, seeding the unified `hex_input` from the current + /// colour (caret at end) so the user edits the live `#RRGGBB`. + pub fn color_picker_focus_hex(&mut self) { + if let Some(state) = self.ui.color_picker.as_mut() { + let (r, g, b) = hsv_to_rgb(state.hue, state.sat, state.val); + state.hex_input = jian_core::text_input::TextInputState::with_text(rgb_to_hex(r, g, b)); + state.hex_focused = true; + } + } + + /// Insert one hex character (or a leading `#`) at the caret, capped at + /// `#RRGGBB`, then live-apply when the draft is a complete colour. Mirrors + /// the property-panel hex input's gating (`keyboard.rs` `is_hex_focus`). + pub fn color_picker_hex_char(&mut self, ch: char, now_ms: u64) { + let Some(state) = self.ui.color_picker.as_mut() else { + return; + }; + if !state.hex_focused { + return; + } + let input = &state.hex_input; + let replacing_all = input.is_select_all(); + let draft = input.text(); + let pos = if replacing_all { + 0 + } else { + input.caret().min(draft.len()) + }; + let allowed = (replacing_all || draft.len() < 7) + && (ch.is_ascii_hexdigit() || (ch == '#' && pos == 0 && !draft.starts_with('#'))); + if !allowed { + return; + } + let mut buf = [0u8; 4]; + let lower = ch.to_ascii_lowercase(); + state + .hex_input + .insert_str(lower.encode_utf8(&mut buf), now_ms); + self.color_picker_apply_hex_draft(); + } + + /// Delete the character before the caret. + pub fn color_picker_hex_backspace(&mut self, now_ms: u64) { + let Some(state) = self.ui.color_picker.as_mut() else { + return; + }; + if !state.hex_focused { + return; + } + state.hex_input.backspace(now_ms); + self.color_picker_apply_hex_draft(); + } + + /// Commit + blur the hex field. No-op when the hex field isn't focused (so + /// callers can blur unconditionally without re-applying a stale draft). + pub fn color_picker_blur_hex(&mut self) { + if !self.color_picker_hex_focused() { + return; + } + self.color_picker_apply_hex_draft(); + if let Some(state) = self.ui.color_picker.as_mut() { + state.hex_focused = false; + } + } + + /// Parse the hex draft and, when it is a valid colour, route it through the + /// normal HSV commit so fill / stroke / variable targets all update. + fn color_picker_apply_hex_draft(&mut self) { + let draft = self + .ui + .color_picker + .as_ref() + .map(|s| s.hex_input.text().to_owned()) + .unwrap_or_default(); + if let Some((r, g, b)) = parse_hex_rgb(&draft) { + let (h, s, v) = rgb_to_hsv((r, g, b)); + self.color_picker_set_hsv(h, s, v); + } + } + + /// Whether one of the R/G/B numeric fields currently has keyboard focus. + pub fn color_picker_rgb_focused(&self) -> bool { + self.ui + .color_picker + .as_ref() + .is_some_and(|s| s.rgb_focus.is_some()) + } + + /// Focus one R/G/B channel (0=R, 1=G, 2=B), seeding `rgb_input` with its + /// current 0..255 value (caret at end). + pub fn color_picker_focus_rgb(&mut self, channel: u8) { + let channel = channel.min(2); + if let Some(state) = self.ui.color_picker.as_mut() { + let (r, g, b) = hsv_to_rgb(state.hue, state.sat, state.val); + let chan = match channel { + 0 => r, + 1 => g, + _ => b, + }; + let value = (chan * 255.0).round() as u8; + state.rgb_input = jian_core::text_input::TextInputState::with_text(value.to_string()); + state.rgb_focus = Some(channel); + } + } + + /// Insert one digit into the focused R/G/B channel (max 3 chars, clamped to + /// 0..255 on apply), then live-apply. + pub fn color_picker_rgb_char(&mut self, ch: char, now_ms: u64) { + let Some(state) = self.ui.color_picker.as_mut() else { + return; + }; + if state.rgb_focus.is_none() { + return; + } + let input = &state.rgb_input; + let allowed = ch.is_ascii_digit() && (input.is_select_all() || input.text().len() < 3); + if !allowed { + return; + } + let mut buf = [0u8; 4]; + state.rgb_input.insert_str(ch.encode_utf8(&mut buf), now_ms); + self.color_picker_apply_rgb_draft(); + } + + /// Delete the character before the caret in the focused R/G/B channel. + pub fn color_picker_rgb_backspace(&mut self, now_ms: u64) { + let Some(state) = self.ui.color_picker.as_mut() else { + return; + }; + if state.rgb_focus.is_none() { + return; + } + state.rgb_input.backspace(now_ms); + self.color_picker_apply_rgb_draft(); + } + + /// Commit + blur the focused R/G/B channel. No-op when none is focused. + pub fn color_picker_blur_rgb(&mut self) { + if !self.color_picker_rgb_focused() { + return; + } + self.color_picker_apply_rgb_draft(); + if let Some(state) = self.ui.color_picker.as_mut() { + state.rgb_focus = None; + } + } + + /// Parse the focused channel's draft (clamped 0..255), splice it into the + /// current RGB, and route the result through the normal HSV commit. + fn color_picker_apply_rgb_draft(&mut self) { + let Some((Some(channel), draft, hue, sat, val)) = self.ui.color_picker.as_ref().map(|s| { + ( + s.rgb_focus, + s.rgb_input.text().to_owned(), + s.hue, + s.sat, + s.val, + ) + }) else { + return; + }; + let Ok(parsed) = draft.parse::() else { + return; + }; + let chan = parsed.min(255) as f32 / 255.0; + let (mut r, mut g, mut b) = hsv_to_rgb(hue, sat, val); + match channel { + 0 => r = chan, + 1 => g = chan, + _ => b = chan, + } + let (h, s, v) = rgb_to_hsv((r, g, b)); + self.color_picker_set_hsv(h, s, v); + } +} diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 2409a0edd..0ba5ad446 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod clipboard; pub mod codegen; pub mod color_convert; pub mod color_picker; +pub mod color_picker_edit; mod color_picker_snapshot; pub mod command; pub mod command_apply; diff --git a/crates/op-editor-core/src/text_input_focus.rs b/crates/op-editor-core/src/text_input_focus.rs index 2754ba73b..dfcee4b22 100644 --- a/crates/op-editor-core/src/text_input_focus.rs +++ b/crates/op-editor-core/src/text_input_focus.rs @@ -20,6 +20,9 @@ impl EditorState { if picker.hex_focused { return Some(&picker.hex_input); } + if picker.rgb_focus.is_some() { + return Some(&picker.rgb_input); + } } if let Some(rename) = &self.ui.layer_rename { return Some(&rename.input); diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index 8eb7b8847..2eac7a299 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -270,6 +270,11 @@ pub struct ColorPickerState { /// through jian `TextInputView` (same unified input + caret as every other /// chrome field), so the host owns caret / selection / blink here. pub hex_input: jian_core::text_input::TextInputState, + /// Which R/G/B channel (0=R, 1=G, 2=B) is focused for keyboard editing. + pub rgb_focus: Option, + /// The unified edit buffer for the focused R/G/B channel (0..255), shared + /// across channels since only one is edited at a time. + pub rgb_input: jian_core::text_input::TextInputState, } /// Transient variable/theme editor state (spec §5.2). diff --git a/crates/op-editor-ui/src/widgets/color_picker.rs b/crates/op-editor-ui/src/widgets/color_picker.rs index b161608f0..6f34287a3 100644 --- a/crates/op-editor-ui/src/widgets/color_picker.rs +++ b/crates/op-editor-ui/src/widgets/color_picker.rs @@ -123,6 +123,7 @@ impl Widget for ColorPicker { eyedropper_icon: Icon::Pencil.paths(), close_icon: Icon::Close.paths(), hex_input: self.state.hex_focused.then_some(&self.state.hex_input), + rgb_input: self.state.rgb_focus.map(|ch| (ch, &self.state.rgb_input)), now_ms: self.now_ms, } .paint( diff --git a/crates/op-host-native/src/widget_host/color_picker_press.rs b/crates/op-host-native/src/widget_host/color_picker_press.rs index 1eab58e2c..53447815f 100644 --- a/crates/op-host-native/src/widget_host/color_picker_press.rs +++ b/crates/op-host-native/src/widget_host/color_picker_press.rs @@ -32,18 +32,27 @@ impl WidgetHostNative { true } Some(ColorPickerHit::HexInput) => { + self.editor_state.color_picker_blur_rgb(); self.editor_state.color_picker_focus_hex(); self.mark_dirty(); true } - Some(ColorPickerHit::Eyedropper) | Some(ColorPickerHit::Inside) => { - // A press elsewhere in the panel commits + blurs the hex field. + Some(ColorPickerHit::RgbInput(channel)) => { self.editor_state.color_picker_blur_hex(); + self.editor_state.color_picker_focus_rgb(channel); + self.mark_dirty(); + true + } + Some(ColorPickerHit::Eyedropper) | Some(ColorPickerHit::Inside) => { + // A press elsewhere in the panel commits + blurs any focused field. + self.editor_state.color_picker_blur_hex(); + self.editor_state.color_picker_blur_rgb(); self.mark_dirty(); true } Some(hit @ (ColorPickerHit::SvBox | ColorPickerHit::HueSlider)) => { self.editor_state.color_picker_blur_hex(); + self.editor_state.color_picker_blur_rgb(); if let Some(kind) = drag_for_hit(hit) { // Live-apply once for the press point. Instance // anchors route through the override redirect diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 0e73be7f5..040e95ee7 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -44,6 +44,7 @@ impl WidgetHostNative { || self.variables_search_active() || self.editor_state.editor_ui.effect_param_focus.is_some() || self.editor_state.color_picker_hex_focused() + || self.editor_state.color_picker_rgb_focused() || self.editor_state.editor_ui.agent_settings.focus.is_some() || self.editor_state.editor_ui.icon_picker.open || self.editor_state.editor_ui.chat_model_picker.open diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index b36923542..fce100171 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -57,6 +57,15 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + // Color-picker R/G/B numeric field owns the keyboard while focused. + if self.editor_state.color_picker_rgb_focused() { + if c.is_control() { + return false; + } + self.editor_state.color_picker_rgb_char(c, self.now_ms); + self.mark_dirty(); + return true; + } // Settings input owns the keyboard while focused. if self.editor_state.editor_ui.agent_settings.focus.is_some() { return self.apply_settings_text(c); @@ -506,6 +515,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.color_picker_rgb_focused() { + self.editor_state.color_picker_rgb_backspace(self.now_ms); + self.mark_dirty(); + return true; + } if self.editor_state.editor_ui.agent_settings.focus.is_some() { return self.apply_settings_backspace(); } @@ -1214,6 +1228,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.color_picker_rgb_focused() { + self.editor_state.color_picker_blur_rgb(); + self.mark_dirty(); + return true; + } if self.editor_state.editor_ui.agent_settings.focus.is_some() { self.commit_settings_focus_if_any(); return true; @@ -1400,6 +1419,11 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self.editor_state.color_picker_rgb_focused() { + self.editor_state.color_picker_blur_rgb(); + self.mark_dirty(); + return true; + } if self .editor_state .editor_ui diff --git a/crates/op-host-web/src/widget_host/color_picker_press.rs b/crates/op-host-web/src/widget_host/color_picker_press.rs index bb871e683..2d8a2149d 100644 --- a/crates/op-host-web/src/widget_host/color_picker_press.rs +++ b/crates/op-host-web/src/widget_host/color_picker_press.rs @@ -31,6 +31,9 @@ impl WidgetHost { self.mark_dirty(); true } + // Hex / RGB boxes: keyboard editing isn't wired on the web host yet, + // so a press just swallows (keeps the picker open). + Some(ColorPickerHit::HexInput) | Some(ColorPickerHit::RgbInput(_)) => true, Some(ColorPickerHit::Eyedropper) | Some(ColorPickerHit::Inside) => true, Some(hit @ (ColorPickerHit::SvBox | ColorPickerHit::HueSlider)) => { if let Some(kind) = drag_for_hit(hit) { diff --git a/vendor/jian b/vendor/jian index cce45239e..d11d54e21 160000 --- a/vendor/jian +++ b/vendor/jian @@ -1 +1 @@ -Subproject commit cce45239e8f746e69771635a4003b511d92a7f46 +Subproject commit d11d54e21dd6604b342c3728dc1ec386268250e5