feat(panels): editable inputs (arrows, caret, fill-opacity, effect params) + paint polish
Property-panel input editing now supports: - Arrow keys: Up/Down step a numeric field; Left/Right move the text caret. Caret position is a real index into the draft, so typing inserts at the caret and Backspace deletes the char before it (not just append/pop). - Layer opacity: full-width box with the localized 不透明度 label inside on the left, value next to it, % at the right edge; clipped so a long-locale label can't bleed past the half-width box. - Fill opacity: new SolidFillBody.opacity getter / setter on the model + PropertyFocus::FillOpacity + the 100 % box in the Fill head row is now editable end-to-end. - Effect-param values: each Drop Shadow X / Y / Blur / Spread cell is a click-to-type input box (new effect_param_focus state, FocusEffectParam action, commit_effect_param_focus_if_any path). The − / + steppers still work alongside. Web's apply_property_action makes the focus a no-op (no keyboard path on web yet) to avoid stranding focus. Paint polish: - Standardised input-text baselines to + 19.0 across prefix / suffix / icon helpers, the fill / stroke / opacity / hex paints, and the export section so every INPUT_HEIGHT row reads on the same baseline. - Icon-prefixed inputs now have 10 px left padding (matching X / Y / W / H) and the icon is vertically centred ((30 - 14) / 2 = 8) instead of sitting at y + 5. - Fill / stroke swatches vertically centred in their hex rows ((30 - 16) / 2 = 7). - '-' / '#' caret-aware validation in apply_text so typing them at caret 0 of a non-empty draft is now a valid edit. Native input_tests seed property_caret_pos to mirror real focus state. Codex review iterations: poison-guard the effect-param focus on web, content-clip the layer-opacity row, fix property-panel scroll clamping in paint, and a few related safety guards across hosts.
This commit is contained in:
parent
2efca5aa3b
commit
46eeaef226
|
|
@ -50,6 +50,19 @@ impl EditorState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Write the anchor node's primary-fill opacity, in `[0.0, 1.0]`.
|
||||
/// Editable-gated. Drives the Fill section's `100 %` input.
|
||||
pub fn set_selected_fill_opacity(&mut self, opacity: f32) -> bool {
|
||||
let sel = self.selection.anchor.clone();
|
||||
if !sel.is_real() || !self.is_editable(&sel) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
|
||||
return false;
|
||||
};
|
||||
crate::fills::set_primary_fill_opacity(node, opacity)
|
||||
}
|
||||
|
||||
/// Append a default drop-shadow effect to the anchor node.
|
||||
/// Editable-gated. Mirrors shell-core's
|
||||
/// `add_drop_shadow_to_selected`.
|
||||
|
|
|
|||
|
|
@ -462,6 +462,15 @@ pub enum VariableRowFocus {
|
|||
String(usize),
|
||||
}
|
||||
|
||||
/// Keyboard focus on an effect-parameter value (the Effects
|
||||
/// section's editable X / Y / Blur / Spread / Radius numbers).
|
||||
/// `effect` is the index of the effect on the selected node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EffectParamFocus {
|
||||
pub effect: usize,
|
||||
pub field: crate::EffectField,
|
||||
}
|
||||
|
||||
/// Editor-UI overlay + panel state — the widget-layer toggles, hover
|
||||
/// targets, menu / modal open flags and panel metrics that the ~30
|
||||
/// editor widgets paint from. Faithful superset of the UI subset
|
||||
|
|
@ -587,6 +596,10 @@ pub struct EditorUiState {
|
|||
pub axis_dropdown_open: Option<String>,
|
||||
/// Editor focus for a non-color variable row (Number / String).
|
||||
pub variable_row_focus: Option<VariableRowFocus>,
|
||||
/// Editor focus on an effect-parameter value (Effects section).
|
||||
/// Shares `UiDraftState.property_input_draft` + caret like the
|
||||
/// variable-row focus does.
|
||||
pub effect_param_focus: Option<EffectParamFocus>,
|
||||
|
||||
// --- Layer / page hover + context menu -------------------------
|
||||
/// Currently-hovered LayerPanel row, or `None`.
|
||||
|
|
@ -721,6 +734,7 @@ impl Default for EditorUiState {
|
|||
fill_type_picker_open: false,
|
||||
axis_dropdown_open: None,
|
||||
variable_row_focus: None,
|
||||
effect_param_focus: None,
|
||||
hovered_layer_id: None,
|
||||
hovered_page_index: None,
|
||||
layer_context_menu: None,
|
||||
|
|
|
|||
|
|
@ -179,6 +179,47 @@ fn solid_fill(hex: String) -> PenFill {
|
|||
})
|
||||
}
|
||||
|
||||
/// Opacity of the node's primary solid fill (1.0 when missing /
|
||||
/// not stored — the canonical default a fresh fill paints with).
|
||||
pub fn first_solid_fill_opacity(node: &PenNode) -> f32 {
|
||||
node_fills(node)
|
||||
.and_then(|f| {
|
||||
f.iter().find_map(|fill| match fill {
|
||||
PenFill::Solid(b) => Some(b.opacity.unwrap_or(1.0)),
|
||||
_ => None,
|
||||
})
|
||||
})
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
/// Write the first `Solid` fill's `opacity` (clamped to `[0.0, 1.0]`).
|
||||
/// When the node has no solid fill, a transparent-black one is
|
||||
/// prepended so the opacity has a target. `false` when the variant
|
||||
/// carries no `fill` field at all.
|
||||
pub fn set_primary_fill_opacity(node: &mut PenNode, opacity: f32) -> bool {
|
||||
let opacity = opacity.clamp(0.0, 1.0);
|
||||
let Some(fills) = node_fills_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(slot) = fills.iter_mut().find_map(|f| match f {
|
||||
PenFill::Solid(body) => Some(body),
|
||||
_ => None,
|
||||
}) {
|
||||
slot.opacity = Some(opacity);
|
||||
} else {
|
||||
fills.insert(
|
||||
0,
|
||||
PenFill::Solid(SolidFillBody {
|
||||
color: "#000000".to_string(),
|
||||
explain: None,
|
||||
opacity: Some(opacity),
|
||||
blend_mode: None,
|
||||
}),
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Replace the first `Solid` fill's colour with `hex`, leaving any
|
||||
/// gradient / image fills untouched. When the node has no solid fill,
|
||||
/// a fresh one is prepended so it paints on top. `false` when the
|
||||
|
|
|
|||
|
|
@ -78,7 +78,10 @@ pub use editor_ui_state::{
|
|||
MergeResolveState, PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode,
|
||||
UpdateStatus, VariableRowFocus,
|
||||
};
|
||||
pub use fills::{first_fill_type, first_solid_fill_hex, first_solid_stroke_hex, node_effects};
|
||||
pub use fills::{
|
||||
first_fill_type, first_solid_fill_hex, first_solid_fill_opacity, first_solid_stroke_hex,
|
||||
node_effects,
|
||||
};
|
||||
pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect};
|
||||
pub use history::{EditorSnapshot, History, HISTORY_CAP};
|
||||
pub use jian_ops_schema::{DesignMdColor, DesignMdSpec, DesignMdTypography};
|
||||
|
|
|
|||
|
|
@ -454,6 +454,12 @@ impl EditorState {
|
|||
| PropertyFocus::Opacity
|
||||
| PropertyFocus::FillHex
|
||||
| PropertyFocus::StrokeHex => {}
|
||||
// Fill opacity is a percentage in the UI — convert to
|
||||
// the canonical `[0.0, 1.0]` and route through the
|
||||
// dedicated fill-opacity setter.
|
||||
PropertyFocus::FillOpacity => {
|
||||
let _ = self.set_selected_fill_opacity((value / 100.0).clamp(0.0, 1.0));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ pub enum PropertyFocus {
|
|||
SizeH,
|
||||
Opacity,
|
||||
FillHex,
|
||||
/// Fill section's `100 %` opacity input — percentage (0..100).
|
||||
FillOpacity,
|
||||
StrokeHex,
|
||||
StrokeWidth,
|
||||
}
|
||||
|
|
@ -137,6 +139,11 @@ pub struct UiDraftState {
|
|||
/// Draft for the focused property input; committed on Enter,
|
||||
/// discarded on Escape.
|
||||
pub property_input_draft: String,
|
||||
/// Caret position (byte index into `property_input_draft`) for
|
||||
/// the focused property input. Property drafts are ASCII, so a
|
||||
/// byte index is also the char index. Typing inserts here and
|
||||
/// Backspace deletes before it; ← / → move it.
|
||||
pub property_caret_pos: usize,
|
||||
/// Caret-blink anchor (ms) for the focused property input — reset
|
||||
/// on focus and on every keystroke.
|
||||
pub property_caret_anchor_ms: u64,
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ pub struct NodeSnapshot {
|
|||
/// Uniform corner radius in doc-px.
|
||||
pub corner_radius: f32,
|
||||
pub fill: Option<Color>,
|
||||
/// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill
|
||||
/// section's `100 %` paints `fill_opacity * 100`.
|
||||
pub fill_opacity: f32,
|
||||
pub stroke: Option<SceneStroke>,
|
||||
/// The node's visual effects, in paint order — drives the
|
||||
/// Effects section's rows + param inputs.
|
||||
|
|
@ -211,6 +214,7 @@ impl NodeSnapshot {
|
|||
rotation_deg: 0.0,
|
||||
corner_radius: 0.0,
|
||||
fill: None,
|
||||
fill_opacity: 1.0,
|
||||
stroke: None,
|
||||
// Multi-select shows no per-effect rows — the Effects
|
||||
// section paints just its header + the add affordance.
|
||||
|
|
@ -253,6 +257,7 @@ impl NodeSnapshot {
|
|||
rotation_deg: base.rotation.unwrap_or(0.0) as f32,
|
||||
corner_radius,
|
||||
fill,
|
||||
fill_opacity: op_editor_core::first_solid_fill_opacity(node),
|
||||
stroke,
|
||||
effects: op_editor_core::node_effects(node)
|
||||
.iter()
|
||||
|
|
@ -296,6 +301,8 @@ pub struct PropertyPanel {
|
|||
/// is focused. The host fills this on click + mutates on
|
||||
/// keystroke; the panel paints it as the field's value.
|
||||
pub draft: String,
|
||||
/// Caret byte-offset into `draft` (ASCII drafts → char index).
|
||||
pub caret_pos: usize,
|
||||
/// Caret-blink anchor (ms since host start) for the focused
|
||||
/// input. Drives the same `jian_core::anim::blink_visible`
|
||||
/// helper the chat caret uses.
|
||||
|
|
@ -331,6 +338,9 @@ pub struct PropertyPanel {
|
|||
/// Active UI locale — threaded into the Fill section so its
|
||||
/// type label / picker / body sub-labels translate.
|
||||
pub locale: op_editor_core::Locale,
|
||||
/// Focused effect-parameter value, if any — drives the Effects
|
||||
/// section's editable value boxes.
|
||||
pub effect_param_focus: Option<op_editor_core::editor_ui_state::EffectParamFocus>,
|
||||
}
|
||||
|
||||
impl PropertyPanel {
|
||||
|
|
@ -413,6 +423,11 @@ impl PropertyPanel {
|
|||
} else {
|
||||
state.ui.property_input_draft.clone()
|
||||
},
|
||||
caret_pos: if is_multi {
|
||||
0
|
||||
} else {
|
||||
state.ui.property_caret_pos
|
||||
},
|
||||
caret_anchor_ms: state.ui.property_caret_anchor_ms,
|
||||
now_ms,
|
||||
flex_layout: ui.flex_layout,
|
||||
|
|
@ -434,6 +449,12 @@ impl PropertyPanel {
|
|||
export_picker_hover: ui.export_picker_hover,
|
||||
scroll: ui.property_panel_scroll.max(0.0),
|
||||
locale: ui.locale,
|
||||
// Inert in the multi-select aggregate view.
|
||||
effect_param_focus: if is_multi {
|
||||
None
|
||||
} else {
|
||||
ui.effect_param_focus
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -627,6 +648,7 @@ impl Widget for PropertyPanel {
|
|||
let edit_ctx = sections::EditContext {
|
||||
focus: self.focus,
|
||||
draft: self.draft.as_str(),
|
||||
caret: self.caret_pos,
|
||||
caret_anchor_ms: self.caret_anchor_ms,
|
||||
now_ms: self.now_ms,
|
||||
};
|
||||
|
|
@ -728,6 +750,8 @@ impl Widget for PropertyPanel {
|
|||
&self.theme,
|
||||
&self.labels,
|
||||
&self.snapshot.effects,
|
||||
&edit_ctx,
|
||||
self.effect_param_focus,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
|
|
|
|||
|
|
@ -56,4 +56,12 @@ pub enum PropertyPanelAction {
|
|||
field: op_editor_core::EffectField,
|
||||
new_value: f32,
|
||||
},
|
||||
/// User clicked an effect parameter's value — host focuses it
|
||||
/// for keyboard entry (`editor_ui.effect_param_focus`). `value`
|
||||
/// is the current committed value, used to seed the draft.
|
||||
FocusEffectParam {
|
||||
effect: usize,
|
||||
field: op_editor_core::EffectField,
|
||||
value: f32,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,28 @@ use crate::theme::Theme;
|
|||
use crate::widgets::icons::{draw_icon, Icon};
|
||||
use crate::widgets::property_panel::EffectSummary;
|
||||
use crate::widgets::property_panel_inputs::{
|
||||
paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_HEIGHT, PAD_X,
|
||||
SECTION_GAP,
|
||||
paint_section_divider, paint_section_label_with_add, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS,
|
||||
PAD_X, SECTION_GAP,
|
||||
};
|
||||
use crate::widgets::property_panel_layout::{
|
||||
effect_param_fields, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT,
|
||||
effect_param_fields, effect_param_value_rect, EFFECT_PARAM_ROW_HEIGHT, EFFECT_ROW_HEIGHT,
|
||||
};
|
||||
use crate::widgets::property_panel_sections::PropertyLabels;
|
||||
use crate::widgets::property_panel_sections::{EditContext, PropertyLabels};
|
||||
use crate::widgets::PaintCx;
|
||||
use crate::{Point2D, Rect, TextLayout};
|
||||
use op_editor_core::editor_ui_state::EffectParamFocus;
|
||||
|
||||
// ── Effects section ───────────────────────────────────────────────
|
||||
|
||||
// Paint-context + geometry args threaded through; a struct adds no gain.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint_effects_section(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
labels: &PropertyLabels,
|
||||
effects: &[EffectSummary],
|
||||
edit: &EditContext<'_>,
|
||||
effect_focus: Option<EffectParamFocus>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
|
|
@ -32,11 +37,28 @@ pub fn paint_effects_section(
|
|||
if effects.is_empty() {
|
||||
row_y += 8.0;
|
||||
} else {
|
||||
for eff in effects {
|
||||
for (ei, eff) in effects.iter().enumerate() {
|
||||
paint_effect_row(cx, theme, eff, x, row_y, width);
|
||||
row_y += EFFECT_ROW_HEIGHT;
|
||||
for &(field, label) in effect_param_fields(eff.kind) {
|
||||
paint_effect_param_row(cx, theme, label, eff.param_value(field), x, row_y, width);
|
||||
let focused = effect_focus == Some(EffectParamFocus { effect: ei, field });
|
||||
let caret = if focused && edit.caret_blink_on() {
|
||||
Some(edit.caret.min(edit.draft.len()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
paint_effect_param_row(
|
||||
cx,
|
||||
theme,
|
||||
label,
|
||||
eff.param_value(field),
|
||||
focused,
|
||||
edit.draft,
|
||||
caret,
|
||||
x,
|
||||
row_y,
|
||||
width,
|
||||
);
|
||||
row_y += EFFECT_PARAM_ROW_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
|
@ -45,14 +67,20 @@ pub fn paint_effects_section(
|
|||
row_y + SECTION_GAP
|
||||
}
|
||||
|
||||
/// Paint one effect-parameter row: `<label> <value> [−] [+]`. The
|
||||
/// "−"/"+" stepper rects must match `action_button_rects`'s
|
||||
/// `AdjustEffectParam` rects exactly so paint + hit-test agree.
|
||||
/// Paint one effect-parameter row: `<label> [value] [−] [+]`. The
|
||||
/// value box is click-to-type; when `focused` it shows the live
|
||||
/// `draft` + caret instead of the committed `value`. The "−"/"+"
|
||||
/// stepper rects must match `action_button_rects`'s `AdjustEffectParam`
|
||||
/// rects exactly so paint + hit-test agree.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_effect_param_row(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
label: &str,
|
||||
value: f32,
|
||||
focused: bool,
|
||||
draft: &str,
|
||||
caret: Option<usize>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
width: f32,
|
||||
|
|
@ -66,9 +94,19 @@ fn paint_effect_param_row(
|
|||
);
|
||||
cx.backend
|
||||
.draw_text(&label_layout, Point2D::new(x + PAD_X + 4.0, y + 15.0));
|
||||
let value_text = format!("{value:.0}");
|
||||
// Editable value box — shows the live draft while focused.
|
||||
let box_rect = effect_param_value_rect(x, y, width);
|
||||
cx.backend
|
||||
.fill_round_rect(box_rect, INPUT_RADIUS, theme.muted);
|
||||
if focused {
|
||||
cx.backend
|
||||
.stroke_round_rect(box_rect, INPUT_RADIUS, theme.primary, 1.5);
|
||||
}
|
||||
let value_owned = format!("{value:.0}");
|
||||
let text = if focused { draft } else { value_owned.as_str() };
|
||||
let text_x = box_rect.origin.x + 10.0;
|
||||
let value_layout = TextLayout::single_run(
|
||||
&value_text,
|
||||
text,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.foreground),
|
||||
|
|
@ -76,8 +114,18 @@ fn paint_effect_param_row(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&value_layout,
|
||||
Point2D::new(x + width - PAD_X - 78.0, y + 15.0),
|
||||
Point2D::new(text_x, box_rect.origin.y + 16.0),
|
||||
);
|
||||
if let Some(pos) = caret {
|
||||
let caret_w = cx.backend.measure_text(&text[..pos.min(text.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(text_x + caret_w, box_rect.origin.y + 4.0),
|
||||
size: Point2D::new(1.5, box_rect.size.y - 8.0),
|
||||
},
|
||||
theme.foreground,
|
||||
);
|
||||
}
|
||||
// "−" then "+" — geometry mirrors the `AdjustEffectParam` rects.
|
||||
for (icon, off) in [(Icon::Minus, 48.0_f32), (Icon::Plus, 22.0_f32)] {
|
||||
let r = Rect {
|
||||
|
|
|
|||
|
|
@ -242,7 +242,7 @@ pub fn paint_fill_section(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&label,
|
||||
Point2D::new(dropdown_rect.origin.x + 10.0, dropdown_rect.origin.y + 17.0),
|
||||
Point2D::new(dropdown_rect.origin.x + 10.0, dropdown_rect.origin.y + 19.0),
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -261,17 +261,35 @@ pub fn paint_fill_section(
|
|||
};
|
||||
cx.backend
|
||||
.fill_round_rect(pct_rect, INPUT_RADIUS, theme.muted);
|
||||
let opacity_focused = edit.focus == Some(PropertyFocus::FillOpacity);
|
||||
if opacity_focused {
|
||||
cx.backend
|
||||
.stroke_round_rect(pct_rect, INPUT_RADIUS, theme.primary, 1.5);
|
||||
}
|
||||
let opacity_owned = ((snapshot.fill_opacity * 100.0).round() as i32).to_string();
|
||||
let pct_text = edit.value_for(PropertyFocus::FillOpacity, &opacity_owned);
|
||||
let pct = TextLayout::single_run(
|
||||
"100",
|
||||
pct_text,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.foreground),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
&pct,
|
||||
Point2D::new(pct_rect.origin.x + 10.0, pct_rect.origin.y + 17.0),
|
||||
);
|
||||
let pct_x = pct_rect.origin.x + 10.0;
|
||||
cx.backend
|
||||
.draw_text(&pct, Point2D::new(pct_x, pct_rect.origin.y + 19.0));
|
||||
if let Some(pos) = edit.caret_at(PropertyFocus::FillOpacity) {
|
||||
let w = cx
|
||||
.backend
|
||||
.measure_text(&pct_text[..pos.min(pct_text.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(pct_x + w, pct_rect.origin.y + 6.0),
|
||||
size: Point2D::new(1.5, pct_rect.size.y - 12.0),
|
||||
},
|
||||
theme.foreground,
|
||||
);
|
||||
}
|
||||
let pct_unit = TextLayout::single_run(
|
||||
"%",
|
||||
"system-ui",
|
||||
|
|
@ -283,7 +301,7 @@ pub fn paint_fill_section(
|
|||
&pct_unit,
|
||||
Point2D::new(
|
||||
pct_rect.origin.x + pct_rect.size.x - 14.0,
|
||||
pct_rect.origin.y + 17.0,
|
||||
pct_rect.origin.y + 19.0,
|
||||
),
|
||||
);
|
||||
draw_icon(
|
||||
|
|
@ -342,7 +360,8 @@ fn paint_fill_solid_body(
|
|||
}
|
||||
cx.backend.fill_round_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 5.0),
|
||||
// Vertically centre the 16-tall swatch in the 30-tall row.
|
||||
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 7.0),
|
||||
size: Point2D::new(16.0, 16.0),
|
||||
},
|
||||
3.0,
|
||||
|
|
@ -357,9 +376,11 @@ fn paint_fill_solid_body(
|
|||
);
|
||||
let hex_x = hex_rect.origin.x + 30.0;
|
||||
cx.backend
|
||||
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 17.0));
|
||||
if edit.caret_visible(PropertyFocus::FillHex) {
|
||||
let w = cx.backend.measure_text(hex_text, 12.0);
|
||||
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0));
|
||||
if let Some(pos) = edit.caret_at(PropertyFocus::FillHex) {
|
||||
let w = cx
|
||||
.backend
|
||||
.measure_text(&hex_text[..pos.min(hex_text.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0),
|
||||
|
|
@ -398,7 +419,7 @@ fn paint_fill_gradient_body(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&prefix,
|
||||
Point2D::new(angle_rect.origin.x + 10.0, angle_rect.origin.y + 17.0),
|
||||
Point2D::new(angle_rect.origin.x + 10.0, angle_rect.origin.y + 19.0),
|
||||
);
|
||||
let value = TextLayout::single_run(
|
||||
"0",
|
||||
|
|
@ -409,7 +430,7 @@ fn paint_fill_gradient_body(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&value,
|
||||
Point2D::new(angle_rect.origin.x + 44.0, angle_rect.origin.y + 17.0),
|
||||
Point2D::new(angle_rect.origin.x + 44.0, angle_rect.origin.y + 19.0),
|
||||
);
|
||||
let unit = TextLayout::single_run(
|
||||
"°",
|
||||
|
|
@ -422,7 +443,7 @@ fn paint_fill_gradient_body(
|
|||
&unit,
|
||||
Point2D::new(
|
||||
angle_rect.origin.x + angle_rect.size.x - 14.0,
|
||||
angle_rect.origin.y + 17.0,
|
||||
angle_rect.origin.y + 19.0,
|
||||
),
|
||||
);
|
||||
yy += INPUT_HEIGHT + 6.0;
|
||||
|
|
@ -478,7 +499,7 @@ fn paint_fill_gradient_body(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&hex_layout,
|
||||
Point2D::new(hex_rect.origin.x + 30.0, hex_rect.origin.y + 17.0),
|
||||
Point2D::new(hex_rect.origin.x + 30.0, hex_rect.origin.y + 19.0),
|
||||
);
|
||||
let pct_rect = Rect {
|
||||
origin: Point2D::new(x + PAD_X + hex_w + 8.0, row_y),
|
||||
|
|
@ -495,7 +516,7 @@ fn paint_fill_gradient_body(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&pct_layout,
|
||||
Point2D::new(pct_rect.origin.x + 12.0, pct_rect.origin.y + 17.0),
|
||||
Point2D::new(pct_rect.origin.x + 12.0, pct_rect.origin.y + 19.0),
|
||||
);
|
||||
let pct_unit = TextLayout::single_run(
|
||||
"%",
|
||||
|
|
@ -508,7 +529,7 @@ fn paint_fill_gradient_body(
|
|||
&pct_unit,
|
||||
Point2D::new(
|
||||
pct_rect.origin.x + pct_rect.size.x - 14.0,
|
||||
pct_rect.origin.y + 17.0,
|
||||
pct_rect.origin.y + 19.0,
|
||||
),
|
||||
);
|
||||
yy += INPUT_HEIGHT + 4.0;
|
||||
|
|
@ -549,6 +570,6 @@ fn paint_fill_image_body(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&label,
|
||||
Point2D::new(row.origin.x + 30.0, row.origin.y + 17.0),
|
||||
Point2D::new(row.origin.x + 30.0, row.origin.y + 19.0),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ pub fn paint_input_with_prefix(
|
|||
prefix: &str,
|
||||
value: &str,
|
||||
) {
|
||||
paint_input_with_prefix_focused(cx, theme, rect, prefix, value, false, false);
|
||||
paint_input_with_prefix_focused(cx, theme, rect, prefix, value, false, None);
|
||||
}
|
||||
|
||||
/// `paint_input_with_prefix` with explicit focus + caret toggle.
|
||||
|
|
@ -88,7 +88,7 @@ pub fn paint_input_with_prefix_focused(
|
|||
prefix: &str,
|
||||
value: &str,
|
||||
focused: bool,
|
||||
caret_visible: bool,
|
||||
caret: Option<usize>,
|
||||
) {
|
||||
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
|
||||
if focused {
|
||||
|
|
@ -121,8 +121,10 @@ pub fn paint_input_with_prefix_focused(
|
|||
);
|
||||
cx.backend
|
||||
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
|
||||
if caret_visible {
|
||||
let value_w = cx.backend.measure_text(value, 12.0);
|
||||
if let Some(pos) = caret {
|
||||
let value_w = cx
|
||||
.backend
|
||||
.measure_text(&value[..pos.min(value.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
|
||||
|
|
@ -141,7 +143,7 @@ pub fn paint_input_with_suffix(
|
|||
value: &str,
|
||||
unit: &str,
|
||||
) {
|
||||
paint_input_with_suffix_focused(cx, theme, rect, value, unit, false, false);
|
||||
paint_input_with_suffix_focused(cx, theme, rect, value, unit, false, None);
|
||||
}
|
||||
|
||||
pub fn paint_input_with_suffix_focused(
|
||||
|
|
@ -151,7 +153,7 @@ pub fn paint_input_with_suffix_focused(
|
|||
value: &str,
|
||||
unit: &str,
|
||||
focused: bool,
|
||||
caret_visible: bool,
|
||||
caret: Option<usize>,
|
||||
) {
|
||||
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
|
||||
if focused {
|
||||
|
|
@ -159,7 +161,7 @@ pub fn paint_input_with_suffix_focused(
|
|||
.stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5);
|
||||
}
|
||||
let value_x = rect.origin.x + 10.0;
|
||||
let baseline_y = rect.origin.y + 17.0;
|
||||
let baseline_y = rect.origin.y + 19.0;
|
||||
let value_layout = TextLayout::single_run(
|
||||
value,
|
||||
"system-ui",
|
||||
|
|
@ -169,8 +171,10 @@ pub fn paint_input_with_suffix_focused(
|
|||
);
|
||||
cx.backend
|
||||
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
|
||||
if caret_visible {
|
||||
let value_w = cx.backend.measure_text(value, 12.0);
|
||||
if let Some(pos) = caret {
|
||||
let value_w = cx
|
||||
.backend
|
||||
.measure_text(&value[..pos.min(value.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
|
||||
|
|
@ -188,7 +192,7 @@ pub fn paint_input_with_suffix_focused(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&unit_layout,
|
||||
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 17.0),
|
||||
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 19.0),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +205,7 @@ pub fn paint_input_with_icon(
|
|||
value: &str,
|
||||
unit: Option<&str>,
|
||||
) {
|
||||
paint_input_with_icon_focused(cx, theme, rect, icon, value, unit, false, false);
|
||||
paint_input_with_icon_focused(cx, theme, rect, icon, value, unit, false, None);
|
||||
}
|
||||
|
||||
// Paint-context + geometry args threaded through; a struct adds no gain.
|
||||
|
|
@ -214,23 +218,27 @@ pub fn paint_input_with_icon_focused(
|
|||
value: &str,
|
||||
unit: Option<&str>,
|
||||
focused: bool,
|
||||
caret_visible: bool,
|
||||
caret: Option<usize>,
|
||||
) {
|
||||
cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted);
|
||||
if focused {
|
||||
cx.backend
|
||||
.stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5);
|
||||
}
|
||||
// Icon at 10 px from the left edge — matches the prefix-text
|
||||
// inputs (X / Y / W / H), so a row of mixed icon + prefix
|
||||
// inputs reads as a single column. Vertically centred in the
|
||||
// 30-tall row: `(30 - 14) / 2 == 8`.
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
icon,
|
||||
Point2D::new(rect.origin.x + 6.0, rect.origin.y + 5.0),
|
||||
Point2D::new(rect.origin.x + 10.0, rect.origin.y + 8.0),
|
||||
14.0,
|
||||
theme.muted_foreground,
|
||||
1.4,
|
||||
);
|
||||
let value_x = rect.origin.x + 26.0;
|
||||
let baseline_y = rect.origin.y + 17.0;
|
||||
let value_x = rect.origin.x + 30.0;
|
||||
let baseline_y = rect.origin.y + 19.0;
|
||||
let value_layout = TextLayout::single_run(
|
||||
value,
|
||||
"system-ui",
|
||||
|
|
@ -240,17 +248,24 @@ pub fn paint_input_with_icon_focused(
|
|||
);
|
||||
cx.backend
|
||||
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
|
||||
if caret_visible {
|
||||
let value_w = cx.backend.measure_text(value, 12.0);
|
||||
let value_w = cx.backend.measure_text(value, 12.0);
|
||||
if let Some(pos) = caret {
|
||||
let caret_w = cx
|
||||
.backend
|
||||
.measure_text(&value[..pos.min(value.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(value_x + value_w, rect.origin.y + 6.0),
|
||||
origin: Point2D::new(value_x + caret_w, rect.origin.y + 6.0),
|
||||
size: Point2D::new(1.5, rect.size.y - 12.0),
|
||||
},
|
||||
theme.foreground,
|
||||
);
|
||||
}
|
||||
let _ = value_w;
|
||||
if let Some(u) = unit {
|
||||
// Unit pinned to the box's right edge — keeps the icon
|
||||
// input visually consistent with the suffix-only inputs
|
||||
// ("100 %" etc.) where the unit also anchors right.
|
||||
let unit_layout = TextLayout::single_run(
|
||||
u,
|
||||
"system-ui",
|
||||
|
|
@ -260,7 +275,7 @@ pub fn paint_input_with_icon_focused(
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&unit_layout,
|
||||
Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 17.0),
|
||||
Point2D::new(rect.origin.x + rect.size.x - 14.0, baseline_y),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -277,7 +292,7 @@ pub fn paint_dropdown(cx: &mut PaintCx<'_>, theme: &Theme, rect: Rect, value: &s
|
|||
);
|
||||
cx.backend.draw_text(
|
||||
&value_layout,
|
||||
Point2D::new(rect.origin.x + 12.0, rect.origin.y + 17.0),
|
||||
Point2D::new(rect.origin.x + 12.0, rect.origin.y + 19.0),
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
|
|||
|
|
@ -173,6 +173,17 @@ pub fn effect_param_fields(kind: EffectKind) -> &'static [(EffectField, &'static
|
|||
}
|
||||
}
|
||||
|
||||
/// On-screen rect of an effect-parameter's editable value box — the
|
||||
/// click target that focuses it for keyboard entry. Shared by the
|
||||
/// action-rect walker (hit-test) and `paint_effect_param_row`
|
||||
/// (paint) so the two never drift. `y` is the param row's top.
|
||||
pub fn effect_param_value_rect(x: f32, y: f32, width: f32) -> Rect {
|
||||
Rect {
|
||||
origin: Point2D::new(x + width - PAD_X - 104.0, y + 3.0),
|
||||
size: Point2D::new(52.0, INPUT_HEIGHT - 6.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Total height one effect block consumes — its header row plus one
|
||||
/// row per editable parameter.
|
||||
pub fn effect_block_height(kind: EffectKind) -> f32 {
|
||||
|
|
@ -469,6 +480,15 @@ pub fn action_button_rects_with_fill_picker(
|
|||
size: Point2D::new(22.0, INPUT_HEIGHT - 6.0),
|
||||
},
|
||||
));
|
||||
// The value box — click to type a value directly.
|
||||
out.push((
|
||||
PropertyPanelAction::FocusEffectParam {
|
||||
effect: ei,
|
||||
field,
|
||||
value: cur,
|
||||
},
|
||||
effect_param_value_rect(x0, py, w),
|
||||
));
|
||||
py += EFFECT_PARAM_ROW_HEIGHT;
|
||||
}
|
||||
y += effect_block_height(eff.kind);
|
||||
|
|
@ -623,12 +643,12 @@ pub fn editable_input_rects(
|
|||
}
|
||||
if visible.opacity {
|
||||
y += SECTION_HEADER_HEIGHT;
|
||||
let half = usable_w / 2.0 - 4.0;
|
||||
// Half-width Layer-opacity row — matches `paint_layer_section`.
|
||||
rects.push((
|
||||
PropertyFocus::Opacity,
|
||||
Rect {
|
||||
origin: Point2D::new(x0 + PAD_X, y),
|
||||
size: Point2D::new(half, INPUT_HEIGHT),
|
||||
size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT),
|
||||
},
|
||||
));
|
||||
y += INPUT_HEIGHT + 12.0;
|
||||
|
|
@ -636,6 +656,16 @@ pub fn editable_input_rects(
|
|||
}
|
||||
if visible.fill {
|
||||
y += SECTION_HEADER_HEIGHT;
|
||||
// FillOpacity input — the head row's `100 %` box, sitting
|
||||
// to the right of the fill-type dropdown. Geometry mirrors
|
||||
// `paint_fill_section`'s `pct_rect`.
|
||||
rects.push((
|
||||
PropertyFocus::FillOpacity,
|
||||
Rect {
|
||||
origin: Point2D::new(x0 + w - PAD_X - 78.0, y),
|
||||
size: Point2D::new(50.0, INPUT_HEIGHT),
|
||||
},
|
||||
));
|
||||
y += INPUT_HEIGHT + 6.0;
|
||||
if matches!(visible.fill_type, FillType::Solid) {
|
||||
rects.push((
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ use crate::widgets::icons::{draw_icon, Icon};
|
|||
use crate::widgets::property_panel::NodeSnapshot;
|
||||
use crate::widgets::property_panel_inputs::{
|
||||
format_color_hex, paint_input_with_icon_focused, paint_input_with_prefix_focused,
|
||||
paint_input_with_suffix_focused, paint_section_divider, paint_section_label, to_jian_color,
|
||||
HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT,
|
||||
paint_section_divider, paint_section_label, to_jian_color, HEADER_HEIGHT, INPUT_HEIGHT,
|
||||
INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT,
|
||||
};
|
||||
use crate::widgets::PaintCx;
|
||||
use crate::{Color, Point2D, Rect, TextLayout};
|
||||
|
|
@ -37,6 +37,8 @@ pub enum PropertyPanelHit {
|
|||
pub struct EditContext<'a> {
|
||||
pub focus: Option<PropertyFocus>,
|
||||
pub draft: &'a str,
|
||||
/// Caret byte-index into `draft` (drafts are ASCII).
|
||||
pub caret: usize,
|
||||
pub caret_anchor_ms: u64,
|
||||
pub now_ms: u64,
|
||||
}
|
||||
|
|
@ -117,10 +119,24 @@ impl<'a> EditContext<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether this field currently shows the caret.
|
||||
pub fn caret_visible(&self, focus: PropertyFocus) -> bool {
|
||||
self.focus == Some(focus)
|
||||
/// Whether the caret blink is currently in its visible phase —
|
||||
/// for editable surfaces (effect params) that don't key off a
|
||||
/// `PropertyFocus`.
|
||||
pub fn caret_blink_on(&self) -> bool {
|
||||
jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500)
|
||||
}
|
||||
|
||||
/// Caret byte-offset for `focus` when it is the focused field
|
||||
/// and the blink is on — `None` otherwise. Drives caret paint;
|
||||
/// the offset is clamped into the draft so a stale value is safe.
|
||||
pub fn caret_at(&self, focus: PropertyFocus) -> Option<usize> {
|
||||
if self.focus == Some(focus)
|
||||
&& jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500)
|
||||
{
|
||||
Some(self.caret.min(self.draft.len()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,8 +233,15 @@ pub fn paint_node_header(
|
|||
y: f32,
|
||||
_w: f32,
|
||||
) -> f32 {
|
||||
// Show the node's name so the header matches the LayerPanel
|
||||
// row; fall back to the kind label for an unnamed node.
|
||||
let title = if snapshot.name.is_empty() {
|
||||
snapshot.kind.as_str()
|
||||
} else {
|
||||
snapshot.name.as_str()
|
||||
};
|
||||
let label = TextLayout::single_run(
|
||||
&snapshot.kind,
|
||||
title,
|
||||
"system-ui",
|
||||
14.0,
|
||||
to_jian_color(theme.foreground),
|
||||
|
|
@ -310,7 +333,7 @@ pub fn paint_position_section(
|
|||
"X",
|
||||
edit.value_for(PropertyFocus::PositionX, &x_value),
|
||||
edit.focus == Some(PropertyFocus::PositionX),
|
||||
edit.caret_visible(PropertyFocus::PositionX),
|
||||
edit.caret_at(PropertyFocus::PositionX),
|
||||
);
|
||||
let y_value = snapshot.y.to_string();
|
||||
paint_input_with_prefix_focused(
|
||||
|
|
@ -320,7 +343,7 @@ pub fn paint_position_section(
|
|||
"Y",
|
||||
edit.value_for(PropertyFocus::PositionY, &y_value),
|
||||
edit.focus == Some(PropertyFocus::PositionY),
|
||||
edit.caret_visible(PropertyFocus::PositionY),
|
||||
edit.caret_at(PropertyFocus::PositionY),
|
||||
);
|
||||
y += INPUT_HEIGHT + 6.0;
|
||||
// Rotation input — TS uses RotateCw glyph; we render with an
|
||||
|
|
@ -338,7 +361,7 @@ pub fn paint_position_section(
|
|||
edit.value_for(PropertyFocus::Rotation, &rotation_value),
|
||||
Some("°"),
|
||||
edit.focus == Some(PropertyFocus::Rotation),
|
||||
edit.caret_visible(PropertyFocus::Rotation),
|
||||
edit.caret_at(PropertyFocus::Rotation),
|
||||
);
|
||||
// Corner radius (R) — editable input bound to Node::corner_radius
|
||||
// via PropertyFocus::PositionR.
|
||||
|
|
@ -354,7 +377,7 @@ pub fn paint_position_section(
|
|||
"R",
|
||||
edit.value_for(PropertyFocus::PositionR, &r_value),
|
||||
edit.focus == Some(PropertyFocus::PositionR),
|
||||
edit.caret_visible(PropertyFocus::PositionR),
|
||||
edit.caret_at(PropertyFocus::PositionR),
|
||||
);
|
||||
y += INPUT_HEIGHT + 12.0;
|
||||
paint_section_divider(cx, theme, x, y, width);
|
||||
|
|
@ -452,7 +475,7 @@ pub fn paint_size_section(
|
|||
"W",
|
||||
edit.value_for(PropertyFocus::SizeW, &w_value),
|
||||
edit.focus == Some(PropertyFocus::SizeW),
|
||||
edit.caret_visible(PropertyFocus::SizeW),
|
||||
edit.caret_at(PropertyFocus::SizeW),
|
||||
);
|
||||
let h_value = snapshot.height.to_string();
|
||||
paint_input_with_prefix_focused(
|
||||
|
|
@ -462,7 +485,7 @@ pub fn paint_size_section(
|
|||
"H",
|
||||
edit.value_for(PropertyFocus::SizeH, &h_value),
|
||||
edit.focus == Some(PropertyFocus::SizeH),
|
||||
edit.caret_visible(PropertyFocus::SizeH),
|
||||
edit.caret_at(PropertyFocus::SizeH),
|
||||
);
|
||||
y += INPUT_HEIGHT + 10.0;
|
||||
let row_h = 22.0;
|
||||
|
|
@ -570,19 +593,75 @@ pub fn paint_layer_section(
|
|||
let usable_w = width - PAD_X * 2.0;
|
||||
let row = Rect {
|
||||
origin: Point2D::new(x + PAD_X, y),
|
||||
// Half-width box — the empty right half avoids the over-wide
|
||||
// look of a row spanning the full panel for a single value.
|
||||
size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT),
|
||||
};
|
||||
let focused = edit.focus == Some(PropertyFocus::Opacity);
|
||||
let value = edit.value_for(PropertyFocus::Opacity, "100");
|
||||
paint_input_with_suffix_focused(
|
||||
cx,
|
||||
theme,
|
||||
row,
|
||||
value,
|
||||
"%",
|
||||
focused,
|
||||
edit.caret_visible(PropertyFocus::Opacity),
|
||||
// Left-aligned compound `<label> <value> <unit>` — measuring
|
||||
// the label first so the value never collides with it, then
|
||||
// packing the unit right after the value. Avoids the overlap
|
||||
// a "value-centered" layout produces in narrow boxes when the
|
||||
// label is a wide CJK string ("不透明度" alone is ~50 px).
|
||||
cx.backend.fill_round_rect(row, INPUT_RADIUS, theme.muted);
|
||||
if focused {
|
||||
cx.backend
|
||||
.stroke_round_rect(row, INPUT_RADIUS, theme.primary, 1.5);
|
||||
}
|
||||
// Clip the text paint to the row so a long localized label
|
||||
// (e.g. ru "Непрозрачность") can't bleed past the half-width
|
||||
// box into the neighbouring rail.
|
||||
cx.backend.save();
|
||||
cx.backend.clip_rect(row);
|
||||
let prefix = labels.opacity;
|
||||
let prefix_w = cx.backend.measure_text(prefix, 12.0);
|
||||
let value_w = cx.backend.measure_text(value, 12.0);
|
||||
let baseline_y = y + 19.0;
|
||||
let prefix_x = row.origin.x + 10.0;
|
||||
let prefix_layout = TextLayout::single_run(
|
||||
prefix,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.muted_foreground),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend
|
||||
.draw_text(&prefix_layout, Point2D::new(prefix_x, baseline_y));
|
||||
let value_x = prefix_x + prefix_w + 8.0;
|
||||
let value_layout = TextLayout::single_run(
|
||||
value,
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.foreground),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend
|
||||
.draw_text(&value_layout, Point2D::new(value_x, baseline_y));
|
||||
if let Some(pos) = edit.caret_at(PropertyFocus::Opacity) {
|
||||
let w = cx
|
||||
.backend
|
||||
.measure_text(&value[..pos.min(value.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(value_x + w, y + 6.0),
|
||||
size: Point2D::new(1.5, INPUT_HEIGHT - 12.0),
|
||||
},
|
||||
theme.foreground,
|
||||
);
|
||||
}
|
||||
let unit_layout = TextLayout::single_run(
|
||||
"%",
|
||||
"system-ui",
|
||||
12.0,
|
||||
to_jian_color(theme.muted_foreground),
|
||||
Point2D::new(0.0, 0.0),
|
||||
);
|
||||
cx.backend.draw_text(
|
||||
&unit_layout,
|
||||
Point2D::new(value_x + value_w + 6.0, baseline_y),
|
||||
);
|
||||
cx.backend.restore();
|
||||
y += INPUT_HEIGHT + 12.0;
|
||||
paint_section_divider(cx, theme, x, y, width);
|
||||
y + SECTION_GAP
|
||||
|
|
@ -625,7 +704,9 @@ pub fn paint_stroke_section(
|
|||
}
|
||||
cx.backend.fill_round_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 5.0),
|
||||
// Vertically centre the 16-tall swatch in the 30-tall
|
||||
// row: `(30 - 16) / 2 == 7`.
|
||||
origin: Point2D::new(hex_rect.origin.x + 6.0, hex_rect.origin.y + 7.0),
|
||||
size: Point2D::new(16.0, 16.0),
|
||||
},
|
||||
3.0,
|
||||
|
|
@ -642,9 +723,11 @@ pub fn paint_stroke_section(
|
|||
);
|
||||
let hex_x = hex_rect.origin.x + 30.0;
|
||||
cx.backend
|
||||
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 17.0));
|
||||
if edit.caret_visible(PropertyFocus::StrokeHex) {
|
||||
let w = cx.backend.measure_text(hex_text, 12.0);
|
||||
.draw_text(&hex_layout, Point2D::new(hex_x, hex_rect.origin.y + 19.0));
|
||||
if let Some(pos) = edit.caret_at(PropertyFocus::StrokeHex) {
|
||||
let w = cx
|
||||
.backend
|
||||
.measure_text(&hex_text[..pos.min(hex_text.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(hex_x + w, hex_rect.origin.y + 6.0),
|
||||
|
|
@ -675,9 +758,11 @@ pub fn paint_stroke_section(
|
|||
);
|
||||
let w_x = w_rect.origin.x + 12.0;
|
||||
cx.backend
|
||||
.draw_text(&w_layout, Point2D::new(w_x, w_rect.origin.y + 17.0));
|
||||
if edit.caret_visible(PropertyFocus::StrokeWidth) {
|
||||
let w = cx.backend.measure_text(w_text, 12.0);
|
||||
.draw_text(&w_layout, Point2D::new(w_x, w_rect.origin.y + 19.0));
|
||||
if let Some(pos) = edit.caret_at(PropertyFocus::StrokeWidth) {
|
||||
let w = cx
|
||||
.backend
|
||||
.measure_text(&w_text[..pos.min(w_text.len())], 12.0);
|
||||
cx.backend.fill_rect(
|
||||
Rect {
|
||||
origin: Point2D::new(w_x + w, w_rect.origin.y + 6.0),
|
||||
|
|
|
|||
|
|
@ -41,16 +41,24 @@ impl DesktopApp {
|
|||
consumed = self.host.apply_escape();
|
||||
}
|
||||
Key::Named(NamedKey::ArrowUp) if !self.zoom_modifier && !settings_focused => {
|
||||
consumed = self.host.apply_nudge(0.0, -nudge);
|
||||
// A focused numeric property input steps its value;
|
||||
// otherwise the arrow nudges the selection.
|
||||
consumed =
|
||||
self.host.apply_property_step(nudge) || self.host.apply_nudge(0.0, -nudge);
|
||||
}
|
||||
Key::Named(NamedKey::ArrowDown) if !self.zoom_modifier && !settings_focused => {
|
||||
consumed = self.host.apply_nudge(0.0, nudge);
|
||||
consumed =
|
||||
self.host.apply_property_step(-nudge) || self.host.apply_nudge(0.0, nudge);
|
||||
}
|
||||
Key::Named(NamedKey::ArrowLeft) if !self.zoom_modifier && !settings_focused => {
|
||||
consumed = self.host.apply_nudge(-nudge, 0.0);
|
||||
// A focused property input moves its text caret;
|
||||
// otherwise the arrow nudges the selection.
|
||||
consumed =
|
||||
self.host.apply_property_caret(false) || self.host.apply_nudge(-nudge, 0.0);
|
||||
}
|
||||
Key::Named(NamedKey::ArrowRight) if !self.zoom_modifier && !settings_focused => {
|
||||
consumed = self.host.apply_nudge(nudge, 0.0);
|
||||
consumed =
|
||||
self.host.apply_property_caret(true) || self.host.apply_nudge(nudge, 0.0);
|
||||
}
|
||||
// Cmd/Ctrl+Alt+U/S/I/X — path boolean ops (Paper.js
|
||||
// parity). Gated on `!settings_focused` so they
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ impl WidgetHostNative {
|
|||
|| ui.text_editing.is_some()
|
||||
|| ui.property_focus.is_some()
|
||||
|| self.editor_state.editor_ui.variable_row_focus.is_some()
|
||||
|| self.editor_state.editor_ui.effect_param_focus.is_some()
|
||||
|| self.editor_state.editor_ui.agent_settings.focus.is_some()
|
||||
|| self.editor_state.chat.focused
|
||||
|| self.git_commit_focus_active()
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ fn escape_closes_one_overlay_per_press_in_priority_order() {
|
|||
let mut host = WidgetHostNative::new();
|
||||
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
|
||||
host.editor_state_mut().ui.property_input_draft = "12".to_string();
|
||||
// Focusing an input seeds the caret at the draft's end (the
|
||||
// press path does this); mirror it so the state is faithful.
|
||||
host.editor_state_mut().ui.property_caret_pos = 2;
|
||||
host.editor_state_mut().editor_ui.locale_picker_open = true;
|
||||
host.editor_state_mut().editor_ui.shape_picker_open = true;
|
||||
host.editor_state_mut().editor_ui.fill_type_picker_open = true;
|
||||
|
|
@ -95,9 +98,13 @@ fn backspace_with_property_draft_does_not_delete_selected() {
|
|||
.set_single_selection(NodeId::new("n10"));
|
||||
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionX);
|
||||
host.editor_state_mut().ui.property_input_draft = "123".to_string();
|
||||
// Caret at the draft's end, as a real focus seeds it — Backspace
|
||||
// deletes the char *before* the caret.
|
||||
host.editor_state_mut().ui.property_caret_pos = 3;
|
||||
|
||||
assert!(host.apply_backspace());
|
||||
assert_eq!(host.editor_state().ui.property_input_draft, "12");
|
||||
assert_eq!(host.editor_state().ui.property_caret_pos, 2);
|
||||
// Selection must be untouched.
|
||||
assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,30 +87,58 @@ impl WidgetHostNative {
|
|||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.editor_ui.effect_param_focus.is_some() {
|
||||
// Effect-param value box — numeric, caret-aware insert
|
||||
// into the shared draft (same as a numeric property).
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
let draft = &self.editor_state.ui.property_input_draft;
|
||||
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
|
||||
let allowed = c.is_ascii_digit()
|
||||
|| (c == '-' && pos == 0 && !draft.starts_with('-'))
|
||||
|| (c == '.' && !draft.contains('.'));
|
||||
if !allowed {
|
||||
return false;
|
||||
}
|
||||
let draft = &mut self.editor_state.ui.property_input_draft;
|
||||
draft.insert(pos, c);
|
||||
self.editor_state.ui.property_caret_pos = pos + 1;
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if let Some(focus) = self.editor_state.ui.property_focus {
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
let is_hex_focus = matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex);
|
||||
// Caret byte-index — drafts are ASCII so it is also the
|
||||
// char index. `-` / `#` are gated on the caret being at
|
||||
// the start, NOT on the draft being empty: typing `-` at
|
||||
// the head of an existing `40` is a valid edit (`-40`).
|
||||
let draft = &self.editor_state.ui.property_input_draft;
|
||||
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
|
||||
let allowed = if is_hex_focus {
|
||||
self.editor_state.ui.property_input_draft.len() < 7
|
||||
&& (c.is_ascii_hexdigit()
|
||||
|| (c == '#' && self.editor_state.ui.property_input_draft.is_empty()))
|
||||
draft.len() < 7
|
||||
&& (c.is_ascii_hexdigit() || (c == '#' && pos == 0 && !draft.starts_with('#')))
|
||||
} else {
|
||||
c.is_ascii_digit()
|
||||
|| (c == '-' && self.editor_state.ui.property_input_draft.is_empty())
|
||||
|| (c == '-' && pos == 0 && !draft.starts_with('-'))
|
||||
|| (c == '.'
|
||||
&& matches!(
|
||||
focus,
|
||||
PropertyFocus::Opacity
|
||||
| PropertyFocus::FillOpacity
|
||||
| PropertyFocus::Rotation
|
||||
| PropertyFocus::PositionR
|
||||
| PropertyFocus::StrokeWidth
|
||||
)
|
||||
&& !self.editor_state.ui.property_input_draft.contains('.'))
|
||||
&& !draft.contains('.'))
|
||||
};
|
||||
if !allowed {
|
||||
return false;
|
||||
}
|
||||
self.editor_state.ui.property_input_draft.push(c);
|
||||
// Insert at the caret and advance it.
|
||||
let draft = &mut self.editor_state.ui.property_input_draft;
|
||||
draft.insert(pos, c);
|
||||
self.editor_state.ui.property_caret_pos = pos + 1;
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
|
|
@ -200,9 +228,16 @@ impl WidgetHostNative {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
if self.editor_state.ui.property_focus.is_some() {
|
||||
if self.editor_state.ui.property_focus.is_some()
|
||||
|| self.editor_state.editor_ui.effect_param_focus.is_some()
|
||||
{
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
if self.editor_state.ui.property_input_draft.pop().is_some() {
|
||||
// Delete the char before the caret, then pull it back.
|
||||
let draft = &mut self.editor_state.ui.property_input_draft;
|
||||
let pos = self.editor_state.ui.property_caret_pos.min(draft.len());
|
||||
if pos > 0 {
|
||||
draft.remove(pos - 1);
|
||||
self.editor_state.ui.property_caret_pos = pos - 1;
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
|
|
@ -291,6 +326,101 @@ impl WidgetHostNative {
|
|||
dup
|
||||
}
|
||||
|
||||
/// Up / Down arrow on a focused numeric property input — steps
|
||||
/// the value by `delta` and commits it (like a `−` / `+`
|
||||
/// stepper). Returns `false` when no numeric property input is
|
||||
/// focused, so the caller falls back to nudging the selection.
|
||||
pub fn apply_property_step(&mut self, delta: f32) -> bool {
|
||||
use op_editor_core::ui_draft::PropertyFocus;
|
||||
// Effect-parameter focus: step the value, commit via
|
||||
// `SetEffectParam`, and reflect it back into the draft.
|
||||
if let Some(ef) = self.editor_state.editor_ui.effect_param_focus {
|
||||
let current: f32 = self
|
||||
.editor_state
|
||||
.ui
|
||||
.property_input_draft
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let next = current + delta;
|
||||
let id = self.editor_state.selection.anchor.clone();
|
||||
if id.is_real() {
|
||||
self.editor_state.commit_history();
|
||||
let _ = self
|
||||
.editor_state
|
||||
.apply(op_editor_core::EditorCommand::SetEffectParam {
|
||||
node_id: id,
|
||||
index: ef.effect as u32,
|
||||
field: ef.field,
|
||||
value: next,
|
||||
});
|
||||
}
|
||||
self.editor_state.ui.property_input_draft = if next.fract() == 0.0 {
|
||||
format!("{}", next as i64)
|
||||
} else {
|
||||
format!("{next}")
|
||||
};
|
||||
self.editor_state.ui.property_caret_pos =
|
||||
self.editor_state.ui.property_input_draft.len();
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
let Some(focus) = self.editor_state.ui.property_focus else {
|
||||
return false;
|
||||
};
|
||||
// Hex colour fields aren't numerically steppable.
|
||||
if matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex) {
|
||||
return false;
|
||||
}
|
||||
let current: f32 = self
|
||||
.editor_state
|
||||
.ui
|
||||
.property_input_draft
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let next = current + delta;
|
||||
let _ = self.editor_state.commit_property_edit(focus, next);
|
||||
// Reflect the committed value back into the draft so the
|
||||
// field shows it and a further step builds on the new value.
|
||||
self.editor_state.ui.property_input_draft = if next.fract() == 0.0 {
|
||||
format!("{}", next as i64)
|
||||
} else {
|
||||
format!("{next}")
|
||||
};
|
||||
self.editor_state.ui.property_caret_pos = self.editor_state.ui.property_input_draft.len();
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
true
|
||||
}
|
||||
|
||||
/// Left / Right arrow on a focused property input — moves the
|
||||
/// text caret one character. Returns `false` when no property
|
||||
/// input is focused, so the caller falls back to node-nudge.
|
||||
pub fn apply_property_caret(&mut self, forward: bool) -> bool {
|
||||
if self.editor_state.ui.property_focus.is_none()
|
||||
&& self.editor_state.editor_ui.effect_param_focus.is_none()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let len = self.editor_state.ui.property_input_draft.len();
|
||||
let pos = self.editor_state.ui.property_caret_pos.min(len);
|
||||
let next = if forward {
|
||||
(pos + 1).min(len)
|
||||
} else {
|
||||
pos.saturating_sub(1)
|
||||
};
|
||||
if next != self.editor_state.ui.property_caret_pos {
|
||||
self.editor_state.ui.property_caret_pos = next;
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.mark_dirty();
|
||||
}
|
||||
// Consumed regardless — an arrow over a focused input must
|
||||
// never fall through to nudging the selected node.
|
||||
true
|
||||
}
|
||||
|
||||
/// Arrow-key nudge — translate selection by (dx, dy) doc px.
|
||||
pub fn apply_nudge(&mut self, dx: f32, dy: f32) -> bool {
|
||||
if self.input_active() {
|
||||
|
|
@ -369,6 +499,10 @@ impl WidgetHostNative {
|
|||
self.commit_variable_row_focus_if_any();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.editor_ui.effect_param_focus.is_some() {
|
||||
self.commit_effect_param_focus_if_any();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.ui.property_focus.is_some() {
|
||||
self.commit_property_focus_if_any();
|
||||
return true;
|
||||
|
|
@ -463,6 +597,18 @@ impl WidgetHostNative {
|
|||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if self
|
||||
.editor_state
|
||||
.editor_ui
|
||||
.effect_param_focus
|
||||
.take()
|
||||
.is_some()
|
||||
{
|
||||
self.editor_state.ui.property_input_draft.clear();
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
if self.editor_state.ui.property_focus.take().is_some() {
|
||||
self.editor_state.ui.property_input_draft.clear();
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
|
|
|
|||
|
|
@ -424,6 +424,9 @@ impl WidgetHostNative {
|
|||
// shell-core `PropertyFocus` → op-editor-core.
|
||||
self.editor_state.ui.property_focus = Some(focus);
|
||||
self.editor_state.ui.property_input_draft = initial;
|
||||
// Caret starts at the end of the seeded draft.
|
||||
self.editor_state.ui.property_caret_pos =
|
||||
self.editor_state.ui.property_input_draft.len();
|
||||
self.editor_state.ui.property_caret_anchor_ms = self.now_ms;
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
self.editor_state.chat.focused = false;
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ pub(in crate::widget_host) fn property_focus_initial(
|
|||
F::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
|
||||
F::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
|
||||
F::Opacity => "100".to_string(),
|
||||
F::FillOpacity => ((panel.snapshot.fill_opacity * 100.0).round() as i32).to_string(),
|
||||
F::FillHex => panel
|
||||
.snapshot
|
||||
.fill
|
||||
|
|
|
|||
|
|
@ -114,6 +114,26 @@ impl WidgetHostNative {
|
|||
});
|
||||
}
|
||||
}
|
||||
A::FocusEffectParam {
|
||||
effect,
|
||||
field,
|
||||
value,
|
||||
} => {
|
||||
// Any prior input was committed by the press path's
|
||||
// `commit_property_focus_if_any`; seed this param's
|
||||
// draft from its current value, caret at the end.
|
||||
let ui = &mut self.editor_state.ui;
|
||||
ui.property_input_draft = if value.fract() == 0.0 {
|
||||
format!("{}", value as i64)
|
||||
} else {
|
||||
format!("{value}")
|
||||
};
|
||||
ui.property_caret_pos = ui.property_input_draft.len();
|
||||
ui.property_caret_anchor_ms = self.now_ms;
|
||||
ui.property_draft_select_all = false;
|
||||
self.editor_state.editor_ui.effect_param_focus =
|
||||
Some(op_editor_core::editor_ui_state::EffectParamFocus { effect, field });
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
|
@ -338,9 +358,38 @@ impl WidgetHostNative {
|
|||
self.mark_dirty();
|
||||
}
|
||||
|
||||
/// Commit a pending effect-parameter edit (Effects section's
|
||||
/// editable value box). Parses the shared draft and writes it
|
||||
/// via `SetEffectParam`; a non-numeric draft is discarded.
|
||||
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
|
||||
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
|
||||
return;
|
||||
};
|
||||
self.editor_state.ui.property_draft_select_all = false;
|
||||
let draft = std::mem::take(&mut self.editor_state.ui.property_input_draft);
|
||||
if let Ok(value) = draft.trim().parse::<f32>() {
|
||||
if value.is_finite() {
|
||||
let id = self.editor_state.selection.anchor.clone();
|
||||
if id.is_real() {
|
||||
self.editor_state.commit_history();
|
||||
let _ =
|
||||
self.editor_state
|
||||
.apply(op_editor_core::EditorCommand::SetEffectParam {
|
||||
node_id: id,
|
||||
index: focus.effect as u32,
|
||||
field: focus.field,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
|
||||
// Commit any pending variable-row edit first.
|
||||
// Commit any pending variable-row / effect-param edit first.
|
||||
self.commit_variable_row_focus_if_any();
|
||||
self.commit_effect_param_focus_if_any();
|
||||
let Some(focus) = self.editor_state.ui.property_focus.take() else {
|
||||
return;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ impl WidgetHost {
|
|||
});
|
||||
}
|
||||
}
|
||||
A::FocusEffectParam { .. } => {
|
||||
// No-op on web: the web host has no keyboard path for
|
||||
// property / effect-param text inputs (`apply_text`
|
||||
// has no such branch), so setting `effect_param_focus`
|
||||
// here would strand the focus with no way to type,
|
||||
// commit, or Escape out. The `−` / `+` steppers
|
||||
// (`AdjustEffectParam`) remain the web edit path.
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue