feat(panels): editable R/G/B fields in the colour picker
Click an R/G/B box to edit that channel as a 0..255 value through the unified TextInputView; the host routes keystrokes via color_picker_rgb_* (now_ms-threaded, digit-gated, 3-char cap) and live-applies by splicing the channel into the current RGB and committing through set_hsv. ColorPickerState gains rgb_focus + rgb_input; active_text_input + the text-input gate resolve it; press dispatch focuses/blurs hex vs rgb. Extracts the hex+rgb edit state machines into color_picker_edit.rs to keep color_picker.rs under the 800-line cap. Bumps vendor/jian to d11d54e.
This commit is contained in:
parent
da6e7cd228
commit
d706222f4e
|
|
@ -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.
|
||||
|
|
|
|||
191
crates/op-editor-core/src/color_picker_edit.rs
Normal file
191
crates/op-editor-core/src/color_picker_edit.rs
Normal file
|
|
@ -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::<u32>() 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
/// 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).
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
2
vendor/jian
vendored
2
vendor/jian
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit cce45239e8f746e69771635a4003b511d92a7f46
|
||||
Subproject commit d11d54e21dd6604b342c3728dc1ec386268250e5
|
||||
Loading…
Reference in a new issue