refactor(native): split widget_host/input.rs to honor 800-line cap (Task #49)
`widget_host/input.rs` was 882 lines, over the project's documented
800-line ceiling. Split the `impl WidgetHostNative` block into two
files along a natural boundary:
input.rs (417 lines — pointer / wheel / pan / cursor-move /
release + drag-commit helpers)
keyboard.rs (481 lines — text / backspace / delete / duplicate /
nudge / send / escape / click routing)
Both `impl WidgetHostNative` blocks share the same type, and Rust
allows multiple impl blocks for one type in the same crate so the
public method surface is preserved verbatim. No behavior changes —
every method moved verbatim with its full body + doc-comment.
Module wiring:
crates/openpencil-shell-native/src/widget_host.rs:
`mod keyboard;` registered between `input_tests` and `paint`
in the alphabetical mod list.
Imports adjusted: keyboard.rs picks up only the symbols its
methods reference (PropertyFocus, AIChatHit / AIChatPlaceholder /
LayerPanel / LayoutCx / Toolbar / Widget + TOOLBAR_WIDTH +
TOP_BAR_HEIGHT, Point2D + Rect, TOOLBAR_INSET_X/Y from helpers).
input.rs drops the now-unused imports of those symbols.
Tests: 54 desktop + 250+ shell-core + 20 shell-native all pass.
File-cap check:
input.rs 417 (was 882, well under 800)
keyboard.rs 481 (new, well under 800)
main.rs still pending — Task #55 follow-up (804 lines today, 4 over).
This commit is contained in:
parent
c138dca80d
commit
d5885045c1
|
|
@ -39,6 +39,7 @@ mod helpers;
|
|||
mod input;
|
||||
#[cfg(test)]
|
||||
mod input_tests;
|
||||
mod keyboard;
|
||||
mod paint;
|
||||
mod press;
|
||||
mod property_dispatch;
|
||||
|
|
|
|||
|
|
@ -414,469 +414,4 @@ impl WidgetHostNative {
|
|||
self.drag = None;
|
||||
was_dragging
|
||||
}
|
||||
|
||||
/// Typed-char router: rename → property → chat.
|
||||
pub fn apply_text(&mut self, c: char) -> bool {
|
||||
// Settings input owns the keyboard while focused — swallow every keystroke.
|
||||
if self.document.ui.agent_settings.focus.is_some() {
|
||||
if c.is_ascii_digit() && self.document.ui.settings_input_draft.len() < 5 {
|
||||
self.document.ui.settings_input_draft.push(c);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.document.ui.layer_rename.is_some() && !c.is_control() {
|
||||
let mut s = [0u8; 4];
|
||||
let _ = self.document.rename_append(c.encode_utf8(&mut s));
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() && !c.is_control() {
|
||||
let mut s = [0u8; 4];
|
||||
if self
|
||||
.document
|
||||
.text_edit_append(c.encode_utf8(&mut s), self.now_ms)
|
||||
{
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if let Some(focus) = self.document.ui.property_focus {
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
let is_hex_focus = matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex);
|
||||
// Hex caps at 7 (`#RRGGBB`); numeric accepts digits, leading `-`, and one `.` for float focuses.
|
||||
let allowed = if is_hex_focus {
|
||||
self.document.ui.property_input_draft.len() < 7
|
||||
&& (c.is_ascii_hexdigit()
|
||||
|| (c == '#' && self.document.ui.property_input_draft.is_empty()))
|
||||
} else {
|
||||
c.is_ascii_digit()
|
||||
|| (c == '-' && self.document.ui.property_input_draft.is_empty())
|
||||
|| (c == '.'
|
||||
&& matches!(
|
||||
focus,
|
||||
PropertyFocus::Opacity
|
||||
| PropertyFocus::Rotation
|
||||
| PropertyFocus::PositionR
|
||||
| PropertyFocus::StrokeWidth
|
||||
)
|
||||
&& !self.document.ui.property_input_draft.contains('.'))
|
||||
};
|
||||
if !allowed {
|
||||
return false;
|
||||
}
|
||||
self.document.ui.property_input_draft.push(c);
|
||||
self.document.ui.property_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
if !self.document.chat.focused {
|
||||
return false;
|
||||
}
|
||||
self.document.chat.input.push(c);
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn apply_backspace(&mut self) -> bool {
|
||||
if self.document.ui.agent_settings.focus.is_some() { self.document.ui.settings_input_draft.pop(); return true; }
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
let ok = self.document.rename_backspace();
|
||||
if ok {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
let ok = self.document.text_edit_backspace(self.now_ms);
|
||||
if ok {
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() {
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
if self.document.ui.property_input_draft.pop().is_some() {
|
||||
self.document.ui.property_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.document.chat.focused {
|
||||
if self.document.chat.input.pop().is_some() {
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let snap = self.document.snapshot_for_history();
|
||||
if self.document.delete_selected() {
|
||||
self.document.history_push_past(snap);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete — pops a char from rename / text-edit when active;
|
||||
/// otherwise deletes the selected node.
|
||||
pub fn apply_delete(&mut self) -> bool {
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
let ok = self.document.rename_backspace();
|
||||
if ok {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
let ok = self.document.text_edit_backspace(self.now_ms);
|
||||
if ok {
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() || self.document.chat.focused {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let snap = self.document.snapshot_for_history();
|
||||
if self.document.delete_selected() {
|
||||
self.document.history_push_past(snap);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Cmd-D — duplicate selection as a sibling at +10 doc px.
|
||||
pub fn apply_duplicate(&mut self) -> bool {
|
||||
if self.input_active() {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
self.document.commit_history();
|
||||
self.document
|
||||
.duplicate_selected(&mut self.next_node_id, 10.0)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
self.document.commit_history();
|
||||
self.document.translate_selected(dx, dy);
|
||||
true
|
||||
}
|
||||
|
||||
/// Layer drag release → reorder_before/after/into.
|
||||
pub(in crate::widget_host) fn commit_layer_drag(
|
||||
&mut self,
|
||||
d: super::LayerDragState,
|
||||
viewport_h: f32,
|
||||
) -> bool {
|
||||
if !d.active {
|
||||
// Never moved past threshold — selection on press is the
|
||||
// only effect, nothing more to do.
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(d.source).is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
use openpencil_shell_core::widgets::{DropPosition, LayerPanel, TOP_BAR_HEIGHT};
|
||||
let layer_rect = openpencil_shell_core::Rect {
|
||||
origin: openpencil_shell_core::Point2D::new(0.0, TOP_BAR_HEIGHT),
|
||||
size: openpencil_shell_core::Point2D::new(
|
||||
self.document.ui.layer_panel_width,
|
||||
(viewport_h - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
// Build with source excluded so indicator y matches post-commit.
|
||||
let panel = LayerPanel::from_document_with_drag_source(&self.document, d.source);
|
||||
let cursor = openpencil_shell_core::Point2D::new(d.current_x, d.current_y);
|
||||
let Some(drop) = panel.drop_target_at(layer_rect, cursor) else {
|
||||
return true;
|
||||
};
|
||||
if drop.anchor == d.source {
|
||||
return true; // self-drop no-op
|
||||
}
|
||||
match drop.position {
|
||||
DropPosition::Before => {
|
||||
self.document.reorder_before(d.source, drop.anchor);
|
||||
}
|
||||
DropPosition::After => {
|
||||
self.document.reorder_after(d.source, drop.anchor);
|
||||
}
|
||||
DropPosition::Into => {
|
||||
self.document.reorder_into(d.source, drop.anchor);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn commit_marquee_selection(
|
||||
&mut self,
|
||||
m: super::MarqueeDragState,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
) {
|
||||
// 2 screen-px marquee threshold (TS `useMarqueeStart`).
|
||||
let screen_dx = (m.current_screen_x - m.start_screen_x).abs();
|
||||
let screen_dy = (m.current_screen_y - m.start_screen_y).abs();
|
||||
if screen_dx < 2.0 && screen_dy < 2.0 {
|
||||
return;
|
||||
}
|
||||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
|
||||
let to_doc = |sx: f32, sy: f32| -> Point2D {
|
||||
let local = Point2D::new(sx - cx0, sy - cy0);
|
||||
self.document.viewport.to_document(local)
|
||||
};
|
||||
let p0 = to_doc(m.start_screen_x, m.start_screen_y);
|
||||
let p1 = to_doc(m.current_screen_x, m.current_screen_y);
|
||||
let x = p0.x.min(p1.x);
|
||||
let y = p0.y.min(p1.y);
|
||||
let w = (p1.x - p0.x).abs();
|
||||
let h = (p1.y - p0.y).abs();
|
||||
let rect = Rect::xywh(x, y, w, h);
|
||||
let ids = self.document.nodes_intersecting_doc_rect(rect);
|
||||
if m.additive {
|
||||
// ADD-only: every hit joins the set; already-selected
|
||||
// hits stay selected. TS parity:
|
||||
// `setSelection([...prior, ...newHits], anchor)` —
|
||||
// shift-marquee never removes. If you want to remove
|
||||
// a single node from the set use shift+click.
|
||||
for id in ids {
|
||||
if !self.document.is_selected(id) {
|
||||
// toggle adds it (since it's not in the set).
|
||||
self.document.toggle_selection(id);
|
||||
}
|
||||
}
|
||||
} else if !ids.is_empty() {
|
||||
// Replace with the hit set. Anchor = last hit
|
||||
// (matches TS `setSelection(ids, ids[last])`).
|
||||
let anchor = *ids.last().unwrap();
|
||||
self.document.selected_set = ids;
|
||||
self.document.selected = anchor;
|
||||
}
|
||||
// Empty marquee on plain press already cleared at start;
|
||||
// nothing else to do.
|
||||
}
|
||||
|
||||
pub fn apply_send(&mut self) -> bool {
|
||||
if self.document.ui.agent_settings.focus.is_some() { self.commit_settings_focus_if_any(); return true; }
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
return self.document.rename_commit();
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
return self.document.text_edit_commit();
|
||||
}
|
||||
if self.document.ui.pen_in_progress.is_some() {
|
||||
return self.document.finish_pen_path();
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() { self.commit_property_focus_if_any(); return true; }
|
||||
if self.document.chat.input.trim().is_empty() { return false; }
|
||||
self.document.chat.send();
|
||||
true
|
||||
}
|
||||
|
||||
/// Escape — priority cascade: rename → property → pickers →
|
||||
/// chat → selection. One layer per press.
|
||||
pub fn apply_escape(&mut self) -> bool {
|
||||
// Settings input focus: clear draft first, second press closes modal.
|
||||
if self.document.ui.agent_settings.focus.take().is_some() { self.document.ui.settings_input_draft.clear(); return true; }
|
||||
if self.document.ui.agent_settings_open { self.document.ui.agent_settings_open = false; self.document.ui.agent_settings_drag = None; return true; }
|
||||
if self.document.ui.export_dialog_open { self.document.ui.export_dialog_open = false; return true; }
|
||||
if self.document.ui.figma_import_open { self.document.ui.figma_import_open = false; return true; }
|
||||
if self.document.ui.file_menu_open { self.document.ui.file_menu_open = false; self.document.ui.file_menu_hover = None; return true; }
|
||||
if self.document.rename_cancel() { return true; }
|
||||
if self.document.text_edit_commit() { return true; }
|
||||
if self.document.finish_pen_path() { return true; }
|
||||
if self.document.ui.property_focus.take().is_some() {
|
||||
self.document.ui.property_input_draft.clear();
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.locale_picker_open {
|
||||
self.document.ui.locale_picker_open = false; self.document.ui.locale_picker_hover = None;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.shape_picker_open {
|
||||
self.document.ui.shape_picker_open = false; self.document.ui.shape_picker_hover = None;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.fill_type_picker_open {
|
||||
self.document.ui.fill_type_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.chat.focused {
|
||||
self.document.chat.focused = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.selected.is_real() {
|
||||
self.document.deselect_all();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Primary-button click — routes to AI chat / Toolbar / Layer.
|
||||
pub fn apply_click(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_width: f32,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
// AI chat panel sits above canvas — check first.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
AIChatHit::FocusInput => {
|
||||
self.document.chat.focused = true;
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Send => {
|
||||
self.document.chat.send();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Example(text) => {
|
||||
self.document.chat.input = text;
|
||||
self.document.chat.focused = true;
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::DragHandle => {
|
||||
// Drag handle is handled in apply_press
|
||||
// ahead of this; reaching here is a path
|
||||
// bypass — ignore.
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
self.document.chat.collapsed = !self.document.chat.collapsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Click outside chat panel — defocus the input.
|
||||
let was_focused = self.document.chat.focused;
|
||||
self.document.chat.focused = false;
|
||||
let (cx0, _cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
let toolbar_h = toolbar
|
||||
.layout(&LayoutCx {
|
||||
available_width: TOOLBAR_WIDTH,
|
||||
dpi: 1.0,
|
||||
})
|
||||
.rect
|
||||
.size
|
||||
.y;
|
||||
let toolbar_rect = Rect {
|
||||
origin: Point2D::new(cx0 + TOOLBAR_INSET_X, TOP_BAR_HEIGHT + TOOLBAR_INSET_Y),
|
||||
size: Point2D::new(TOOLBAR_WIDTH, toolbar_h),
|
||||
};
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => return false,
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Panel hits only when sidebar is open.
|
||||
if !self.document.ui.sidebar_open {
|
||||
return was_focused;
|
||||
}
|
||||
let layer_rect = Rect {
|
||||
origin: Point2D::new(0.0, TOP_BAR_HEIGHT),
|
||||
size: Point2D::new(
|
||||
self.document.ui.layer_panel_width,
|
||||
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(layer_rect, Point2D::new(x, y)) {
|
||||
use openpencil_shell_core::document::LayerContextTarget;
|
||||
use openpencil_shell_core::widgets::LayerPanelHit as H;
|
||||
let target_for_dbl = match hit {
|
||||
H::Layer(id) => Some(LayerContextTarget::Layer(id)),
|
||||
H::Page(idx) => Some(LayerContextTarget::Page(idx)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(target) = target_for_dbl {
|
||||
if let Some((prev, prev_ms)) = self.document.ui.last_layer_click {
|
||||
if prev == target && self.now_ms.saturating_sub(prev_ms) < 400 {
|
||||
let started = match target {
|
||||
LayerContextTarget::Layer(id) => self.document.start_rename_layer(id),
|
||||
LayerContextTarget::Page(idx) => self.document.start_rename_page(idx),
|
||||
};
|
||||
if started {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
self.document.ui.last_layer_click = None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.document.ui.last_layer_click = Some((target, self.now_ms));
|
||||
}
|
||||
match hit {
|
||||
H::Page(idx) => {
|
||||
self.document.active_page_index = idx;
|
||||
self.document.clear_selection();
|
||||
return true;
|
||||
}
|
||||
H::Layer(node_id) => {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
H::ToggleHidden(node_id) => {
|
||||
self.document.toggle_node_hidden(node_id);
|
||||
return true;
|
||||
}
|
||||
H::ToggleLocked(node_id) => {
|
||||
self.document.toggle_node_locked(node_id);
|
||||
return true;
|
||||
}
|
||||
H::ToggleCollapsed(node_id) => {
|
||||
self.document.toggle_node_collapsed(node_id);
|
||||
return true;
|
||||
}
|
||||
H::AddPage => {
|
||||
let _ = self.document.add_page();
|
||||
return true;
|
||||
}
|
||||
H::DeletePage(idx) => {
|
||||
let _ = self.document.remove_page(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Click hit no chrome — repaint if focus changed.
|
||||
was_focused
|
||||
}
|
||||
}
|
||||
|
|
|
|||
481
crates/openpencil-shell-native/src/widget_host/keyboard.rs
Normal file
481
crates/openpencil-shell-native/src/widget_host/keyboard.rs
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
//! Keyboard + click handlers on `WidgetHostNative`. Split out of
|
||||
//! `input.rs` to honor the 800-line cap (Task #49). Pointer + wheel
|
||||
//! + cursor-move + release stay in `input.rs`; this file owns text
|
||||
//! input + delete + duplicate + nudge + send + escape + click
|
||||
//! routing.
|
||||
|
||||
use super::helpers::{TOOLBAR_INSET_X, TOOLBAR_INSET_Y};
|
||||
use super::WidgetHostNative;
|
||||
use openpencil_shell_core::document::PropertyFocus;
|
||||
use openpencil_shell_core::widgets::{
|
||||
AIChatHit, AIChatPlaceholder, LayerPanel, LayoutCx, Toolbar, Widget, TOOLBAR_WIDTH,
|
||||
TOP_BAR_HEIGHT,
|
||||
};
|
||||
use openpencil_shell_core::{Point2D, Rect};
|
||||
|
||||
impl WidgetHostNative {
|
||||
/// Typed-char router: rename → property → chat.
|
||||
pub fn apply_text(&mut self, c: char) -> bool {
|
||||
// Settings input owns the keyboard while focused — swallow every keystroke.
|
||||
if self.document.ui.agent_settings.focus.is_some() {
|
||||
if c.is_ascii_digit() && self.document.ui.settings_input_draft.len() < 5 {
|
||||
self.document.ui.settings_input_draft.push(c);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.document.ui.layer_rename.is_some() && !c.is_control() {
|
||||
let mut s = [0u8; 4];
|
||||
let _ = self.document.rename_append(c.encode_utf8(&mut s));
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() && !c.is_control() {
|
||||
let mut s = [0u8; 4];
|
||||
if self
|
||||
.document
|
||||
.text_edit_append(c.encode_utf8(&mut s), self.now_ms)
|
||||
{
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if let Some(focus) = self.document.ui.property_focus {
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
let is_hex_focus = matches!(focus, PropertyFocus::FillHex | PropertyFocus::StrokeHex);
|
||||
// Hex caps at 7 (`#RRGGBB`); numeric accepts digits, leading `-`, and one `.` for float focuses.
|
||||
let allowed = if is_hex_focus {
|
||||
self.document.ui.property_input_draft.len() < 7
|
||||
&& (c.is_ascii_hexdigit()
|
||||
|| (c == '#' && self.document.ui.property_input_draft.is_empty()))
|
||||
} else {
|
||||
c.is_ascii_digit()
|
||||
|| (c == '-' && self.document.ui.property_input_draft.is_empty())
|
||||
|| (c == '.'
|
||||
&& matches!(
|
||||
focus,
|
||||
PropertyFocus::Opacity
|
||||
| PropertyFocus::Rotation
|
||||
| PropertyFocus::PositionR
|
||||
| PropertyFocus::StrokeWidth
|
||||
)
|
||||
&& !self.document.ui.property_input_draft.contains('.'))
|
||||
};
|
||||
if !allowed {
|
||||
return false;
|
||||
}
|
||||
self.document.ui.property_input_draft.push(c);
|
||||
self.document.ui.property_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
if !self.document.chat.focused {
|
||||
return false;
|
||||
}
|
||||
self.document.chat.input.push(c);
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn apply_backspace(&mut self) -> bool {
|
||||
if self.document.ui.agent_settings.focus.is_some() { self.document.ui.settings_input_draft.pop(); return true; }
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
let ok = self.document.rename_backspace();
|
||||
if ok {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
let ok = self.document.text_edit_backspace(self.now_ms);
|
||||
if ok {
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() {
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
if self.document.ui.property_input_draft.pop().is_some() {
|
||||
self.document.ui.property_caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if self.document.chat.focused {
|
||||
if self.document.chat.input.pop().is_some() {
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let snap = self.document.snapshot_for_history();
|
||||
if self.document.delete_selected() {
|
||||
self.document.history_push_past(snap);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete — pops a char from rename / text-edit when active;
|
||||
/// otherwise deletes the selected node.
|
||||
pub fn apply_delete(&mut self) -> bool {
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
let ok = self.document.rename_backspace();
|
||||
if ok {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
let ok = self.document.text_edit_backspace(self.now_ms);
|
||||
if ok {
|
||||
self.document.ui.text_edit_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() || self.document.chat.focused {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
let snap = self.document.snapshot_for_history();
|
||||
if self.document.delete_selected() {
|
||||
self.document.history_push_past(snap);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Cmd-D — duplicate selection as a sibling at +10 doc px.
|
||||
pub fn apply_duplicate(&mut self) -> bool {
|
||||
if self.input_active() {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
self.document.commit_history();
|
||||
self.document
|
||||
.duplicate_selected(&mut self.next_node_id, 10.0)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
return false;
|
||||
}
|
||||
if !self.document.selected.is_real() {
|
||||
return false;
|
||||
}
|
||||
self.document.commit_history();
|
||||
self.document.translate_selected(dx, dy);
|
||||
true
|
||||
}
|
||||
|
||||
/// Layer drag release → reorder_before/after/into.
|
||||
pub(in crate::widget_host) fn commit_layer_drag(
|
||||
&mut self,
|
||||
d: super::LayerDragState,
|
||||
viewport_h: f32,
|
||||
) -> bool {
|
||||
if !d.active {
|
||||
// Never moved past threshold — selection on press is the
|
||||
// only effect, nothing more to do.
|
||||
return false;
|
||||
}
|
||||
if self
|
||||
.document
|
||||
.active_page()
|
||||
.map(|p| p.find(d.source).is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
use openpencil_shell_core::widgets::{DropPosition, LayerPanel, TOP_BAR_HEIGHT};
|
||||
let layer_rect = openpencil_shell_core::Rect {
|
||||
origin: openpencil_shell_core::Point2D::new(0.0, TOP_BAR_HEIGHT),
|
||||
size: openpencil_shell_core::Point2D::new(
|
||||
self.document.ui.layer_panel_width,
|
||||
(viewport_h - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
// Build with source excluded so indicator y matches post-commit.
|
||||
let panel = LayerPanel::from_document_with_drag_source(&self.document, d.source);
|
||||
let cursor = openpencil_shell_core::Point2D::new(d.current_x, d.current_y);
|
||||
let Some(drop) = panel.drop_target_at(layer_rect, cursor) else {
|
||||
return true;
|
||||
};
|
||||
if drop.anchor == d.source {
|
||||
return true; // self-drop no-op
|
||||
}
|
||||
match drop.position {
|
||||
DropPosition::Before => {
|
||||
self.document.reorder_before(d.source, drop.anchor);
|
||||
}
|
||||
DropPosition::After => {
|
||||
self.document.reorder_after(d.source, drop.anchor);
|
||||
}
|
||||
DropPosition::Into => {
|
||||
self.document.reorder_into(d.source, drop.anchor);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(in crate::widget_host) fn commit_marquee_selection(
|
||||
&mut self,
|
||||
m: super::MarqueeDragState,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
) {
|
||||
// 2 screen-px marquee threshold (TS `useMarqueeStart`).
|
||||
let screen_dx = (m.current_screen_x - m.start_screen_x).abs();
|
||||
let screen_dy = (m.current_screen_y - m.start_screen_y).abs();
|
||||
if screen_dx < 2.0 && screen_dy < 2.0 {
|
||||
return;
|
||||
}
|
||||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
|
||||
let to_doc = |sx: f32, sy: f32| -> Point2D {
|
||||
let local = Point2D::new(sx - cx0, sy - cy0);
|
||||
self.document.viewport.to_document(local)
|
||||
};
|
||||
let p0 = to_doc(m.start_screen_x, m.start_screen_y);
|
||||
let p1 = to_doc(m.current_screen_x, m.current_screen_y);
|
||||
let x = p0.x.min(p1.x);
|
||||
let y = p0.y.min(p1.y);
|
||||
let w = (p1.x - p0.x).abs();
|
||||
let h = (p1.y - p0.y).abs();
|
||||
let rect = Rect::xywh(x, y, w, h);
|
||||
let ids = self.document.nodes_intersecting_doc_rect(rect);
|
||||
if m.additive {
|
||||
// ADD-only: every hit joins the set; already-selected
|
||||
// hits stay selected. TS parity:
|
||||
// `setSelection([...prior, ...newHits], anchor)` —
|
||||
// shift-marquee never removes. If you want to remove
|
||||
// a single node from the set use shift+click.
|
||||
for id in ids {
|
||||
if !self.document.is_selected(id) {
|
||||
// toggle adds it (since it's not in the set).
|
||||
self.document.toggle_selection(id);
|
||||
}
|
||||
}
|
||||
} else if !ids.is_empty() {
|
||||
// Replace with the hit set. Anchor = last hit
|
||||
// (matches TS `setSelection(ids, ids[last])`).
|
||||
let anchor = *ids.last().unwrap();
|
||||
self.document.selected_set = ids;
|
||||
self.document.selected = anchor;
|
||||
}
|
||||
// Empty marquee on plain press already cleared at start;
|
||||
// nothing else to do.
|
||||
}
|
||||
|
||||
pub fn apply_send(&mut self) -> bool {
|
||||
if self.document.ui.agent_settings.focus.is_some() { self.commit_settings_focus_if_any(); return true; }
|
||||
if self.document.ui.layer_rename.is_some() {
|
||||
return self.document.rename_commit();
|
||||
}
|
||||
if self.document.ui.text_editing.is_some() {
|
||||
return self.document.text_edit_commit();
|
||||
}
|
||||
if self.document.ui.pen_in_progress.is_some() {
|
||||
return self.document.finish_pen_path();
|
||||
}
|
||||
if self.document.ui.property_focus.is_some() { self.commit_property_focus_if_any(); return true; }
|
||||
if self.document.chat.input.trim().is_empty() { return false; }
|
||||
self.document.chat.send();
|
||||
true
|
||||
}
|
||||
|
||||
/// Escape — priority cascade: rename → property → pickers →
|
||||
/// chat → selection. One layer per press.
|
||||
pub fn apply_escape(&mut self) -> bool {
|
||||
// Settings input focus: clear draft first, second press closes modal.
|
||||
if self.document.ui.agent_settings.focus.take().is_some() { self.document.ui.settings_input_draft.clear(); return true; }
|
||||
if self.document.ui.agent_settings_open { self.document.ui.agent_settings_open = false; self.document.ui.agent_settings_drag = None; return true; }
|
||||
if self.document.ui.export_dialog_open { self.document.ui.export_dialog_open = false; return true; }
|
||||
if self.document.ui.figma_import_open { self.document.ui.figma_import_open = false; return true; }
|
||||
if self.document.ui.file_menu_open { self.document.ui.file_menu_open = false; self.document.ui.file_menu_hover = None; return true; }
|
||||
if self.document.rename_cancel() { return true; }
|
||||
if self.document.text_edit_commit() { return true; }
|
||||
if self.document.finish_pen_path() { return true; }
|
||||
if self.document.ui.property_focus.take().is_some() {
|
||||
self.document.ui.property_input_draft.clear();
|
||||
self.document.ui.property_draft_select_all = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.locale_picker_open {
|
||||
self.document.ui.locale_picker_open = false; self.document.ui.locale_picker_hover = None;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.shape_picker_open {
|
||||
self.document.ui.shape_picker_open = false; self.document.ui.shape_picker_hover = None;
|
||||
return true;
|
||||
}
|
||||
if self.document.ui.fill_type_picker_open {
|
||||
self.document.ui.fill_type_picker_open = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.chat.focused {
|
||||
self.document.chat.focused = false;
|
||||
return true;
|
||||
}
|
||||
if self.document.selected.is_real() {
|
||||
self.document.deselect_all();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Primary-button click — routes to AI chat / Toolbar / Layer.
|
||||
pub fn apply_click(
|
||||
&mut self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_width: f32,
|
||||
viewport_height: f32,
|
||||
) -> bool {
|
||||
// AI chat panel sits above canvas — check first.
|
||||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
AIChatHit::FocusInput => {
|
||||
self.document.chat.focused = true;
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Send => {
|
||||
self.document.chat.send();
|
||||
return true;
|
||||
}
|
||||
AIChatHit::Example(text) => {
|
||||
self.document.chat.input = text;
|
||||
self.document.chat.focused = true;
|
||||
self.document.chat.caret_anchor_ms = self.now_ms;
|
||||
return true;
|
||||
}
|
||||
AIChatHit::DragHandle => {
|
||||
// Drag handle is handled in apply_press
|
||||
// ahead of this; reaching here is a path
|
||||
// bypass — ignore.
|
||||
return false;
|
||||
}
|
||||
AIChatHit::ToggleCollapse => {
|
||||
self.document.chat.collapsed = !self.document.chat.collapsed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Click outside chat panel — defocus the input.
|
||||
let was_focused = self.document.chat.focused;
|
||||
self.document.chat.focused = false;
|
||||
let (cx0, _cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
|
||||
let toolbar = Toolbar::for_document(&self.document);
|
||||
let toolbar_h = toolbar
|
||||
.layout(&LayoutCx {
|
||||
available_width: TOOLBAR_WIDTH,
|
||||
dpi: 1.0,
|
||||
})
|
||||
.rect
|
||||
.size
|
||||
.y;
|
||||
let toolbar_rect = Rect {
|
||||
origin: Point2D::new(cx0 + TOOLBAR_INSET_X, TOP_BAR_HEIGHT + TOOLBAR_INSET_Y),
|
||||
size: Point2D::new(TOOLBAR_WIDTH, toolbar_h),
|
||||
};
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
match hit {
|
||||
openpencil_shell_core::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.document.tool = tool;
|
||||
return true;
|
||||
}
|
||||
openpencil_shell_core::widgets::ToolbarHit::Action(_) => return false,
|
||||
openpencil_shell_core::widgets::ToolbarHit::ToggleShapePicker => {
|
||||
self.document.ui.shape_picker_open = !self.document.ui.shape_picker_open;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Panel hits only when sidebar is open.
|
||||
if !self.document.ui.sidebar_open {
|
||||
return was_focused;
|
||||
}
|
||||
let layer_rect = Rect {
|
||||
origin: Point2D::new(0.0, TOP_BAR_HEIGHT),
|
||||
size: Point2D::new(
|
||||
self.document.ui.layer_panel_width,
|
||||
(viewport_height - TOP_BAR_HEIGHT).max(0.0),
|
||||
),
|
||||
};
|
||||
let panel = LayerPanel::from_document(&self.document);
|
||||
if let Some(hit) = panel.hit_test(layer_rect, Point2D::new(x, y)) {
|
||||
use openpencil_shell_core::document::LayerContextTarget;
|
||||
use openpencil_shell_core::widgets::LayerPanelHit as H;
|
||||
let target_for_dbl = match hit {
|
||||
H::Layer(id) => Some(LayerContextTarget::Layer(id)),
|
||||
H::Page(idx) => Some(LayerContextTarget::Page(idx)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(target) = target_for_dbl {
|
||||
if let Some((prev, prev_ms)) = self.document.ui.last_layer_click {
|
||||
if prev == target && self.now_ms.saturating_sub(prev_ms) < 400 {
|
||||
let started = match target {
|
||||
LayerContextTarget::Layer(id) => self.document.start_rename_layer(id),
|
||||
LayerContextTarget::Page(idx) => self.document.start_rename_page(idx),
|
||||
};
|
||||
if started {
|
||||
self.document.ui.rename_caret_anchor_ms = self.now_ms;
|
||||
}
|
||||
self.document.ui.last_layer_click = None;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.document.ui.last_layer_click = Some((target, self.now_ms));
|
||||
}
|
||||
match hit {
|
||||
H::Page(idx) => {
|
||||
self.document.active_page_index = idx;
|
||||
self.document.clear_selection();
|
||||
return true;
|
||||
}
|
||||
H::Layer(node_id) => {
|
||||
if self.shift_held {
|
||||
self.document.toggle_selection(node_id);
|
||||
} else {
|
||||
self.document.set_single_selection(node_id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
H::ToggleHidden(node_id) => {
|
||||
self.document.toggle_node_hidden(node_id);
|
||||
return true;
|
||||
}
|
||||
H::ToggleLocked(node_id) => {
|
||||
self.document.toggle_node_locked(node_id);
|
||||
return true;
|
||||
}
|
||||
H::ToggleCollapsed(node_id) => {
|
||||
self.document.toggle_node_collapsed(node_id);
|
||||
return true;
|
||||
}
|
||||
H::AddPage => {
|
||||
let _ = self.document.add_page();
|
||||
return true;
|
||||
}
|
||||
H::DeletePage(idx) => {
|
||||
let _ = self.document.remove_page(idx);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Click hit no chrome — repaint if focus changed.
|
||||
was_focused
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue