zaniah 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bad32e7fbe4f1abe64589fbc41d73f242c4ed3aeee9f760c67a4d205d0c8d8d7
4
- data.tar.gz: b57324bacd30eeea83d04407f689e9ca47d79178d1f0228acf5396fa2ea27881
3
+ metadata.gz: 3a6d77e1edea911ee42064986e375f163213f45bedaeda9c6f3e409b42914cbd
4
+ data.tar.gz: 21970a583f79a01502abf982811820361a7628dc0ffffca0a2f308fa4852a6b7
5
5
  SHA512:
6
- metadata.gz: 5bbf0bebdad77e095cb6faba2a614cf5129558dd10629c94f2e6ad9966f9cb50dfe32a37b551569cdcfb1ba74085d4c64a3667740ff1699fa7b8ce03c553ad4c
7
- data.tar.gz: 9d2491ddb19ce68065497f484b9bba0aa4e026fa998a31e3bca3d7bd41f0ed6edd805f0096aefd28ce2b1acf9309f0b96785cbbce88d67ebea10526094060a2f
6
+ metadata.gz: 83d536f11608784994079181c05524fb3935cddf6076728967b78fc54b48ae91889c387efc047159f767dac8743ae5cb89f7c3497f75afa7b733133f6bc7e982
7
+ data.tar.gz: caa3b692cbe5435abc9571b0ccc3a09dc46db71c022a0ab2f80903dab7336c3818c3ae05ecc3ee6a0aa8ba657fb45bfa61a5218ec236546f229ba9d80614ace2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 — 2026-09-25
4
+
5
+ - Add segmented controls, alerts, hover cards, calendar and date-range inputs.
6
+ - Add zoomable views, dockable workspaces, and editable property grids.
7
+ - Add typed drag-and-drop across desktop backends with explicit move completion.
8
+
3
9
  ## 0.10.0 — 2026-09-24
4
10
 
5
11
  - Add Unicode bidirectional text layout, caret affinity, and Arabic joining.
@@ -0,0 +1,33 @@
1
+ # ADR 021: Represent dock layouts as a validated tree
2
+
3
+ - Status: Accepted
4
+ - Date: 2026-09-24
5
+
6
+ ## Context
7
+
8
+ An application needs to save tabbed, split workspaces and restore them across
9
+ sessions. Dragging a tab changes both its group and potentially the split tree.
10
+ A fixed five-region dock cannot represent nested splits or stable panel identity.
11
+
12
+ ## Decision
13
+
14
+ `UI::DockLayout` stores tab groups and binary splits with stable string IDs.
15
+ `to_h` and `from_h` provide a plain-data round trip; parsing rejects duplicate
16
+ IDs, empty groups, invalid active tabs, invalid orientations, and out-of-range
17
+ ratios. `UI::DockWorkspace` derives visible panes from this model and reuses
18
+ `UI::SplitPane` for dividers. Layout changes produce a new validated model.
19
+ The application owns persistence and handles `on_detach` notifications; the
20
+ component does not create windows.
21
+
22
+ ## Alternatives considered
23
+
24
+ - A flat list of panels plus coordinates would make nested resizing and
25
+ restoration ambiguous.
26
+ - Extending `DockPanel` would change its simple five-region contract.
27
+ - Making detach automatically create windows would assume application policy.
28
+
29
+ ## Consequences
30
+
31
+ Persisted layouts can be validated independently of rendering. Empty groups
32
+ are pruned after a move. Applications can choose their own storage format and
33
+ window ownership; they must keep panel IDs stable across releases.
data/docs/components.md CHANGED
@@ -32,6 +32,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
32
32
  | L0 | `Card` | `(*children)`, `child` | theme surface | group |
33
33
  | L0 | `Badge` | `(text, variant:)` | neutral/accent/success/warning/danger | text |
34
34
  | L0 | `Kbd` | `(keys, platform:)`; `.for(action, keymap:)` | OS shortcut notation, including multi-stroke bindings | text |
35
+ | L0 | `Alert` | `(title, message:, variant:, action:, dismissible:, live:)`; `dismiss` | info/success/warning/danger; persistent until dismissed | alert when live, otherwise status |
35
36
  | L0 | `Avatar` | `(name, image:, size:)` | initials or PNG | image |
36
37
  | L0 | `Skeleton` | `(width:, height:)` | pulsing loading placeholder | progressbar/busy |
37
38
  | L0 | `EmptyState` | `(title, message:, icon:, action:)` | compositional | group |
@@ -42,6 +43,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
42
43
  | L1 | `Checkbox` | `(label, value:, disabled:)`; `on_change` | true/false/mixed | checkbox |
43
44
  | L1 | `Radio` | `(label, value:, disabled:)`; `on_change` | selected/unselected | radio |
44
45
  | L1 | `RadioGroup` | `(options, value:)`; `on_change` | one selected value | radiogroup |
46
+ | L1 | `SegmentedControl` | `(options, value:)`; `on_change` | mutually exclusive segments; arrows/Home/End | radiogroup/radio |
45
47
  | L1 | `Switch` | `(label, value:, disabled:)`; `on_change` | on/off | switch |
46
48
  | L1 | `Slider` | `(value:, min:, max:, step:, label:)`; `on_change` | pointer + arrow/Home/End/Page keys | slider |
47
49
  | L1 | `RangeSlider` | `(value: [low, high], ...)` | two thumbs | slider |
@@ -50,6 +52,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
50
52
  | L1 | `Meter` | `(value:, low:, high:, optimum:)` | thresholds | meter |
51
53
  | L2 | `Tooltip` | `(text, anchor:, side:, open:)` | top/bottom/left/right | tooltip |
52
54
  | L2 | `Popover` | `(content, anchor:, side:, width:, height:, open:, modal:)` | flipped and viewport-clamped | group |
55
+ | L2 | `HoverCard` | `(content, anchor:, open_delay:, close_delay:)` | hover or focus opens after a delay; non-modal | dialog |
53
56
  | L2 | `ContextMenu`, `Menu` | `(items, anchor:, open:)` | pointer + arrows/Home/End/Enter/Esc | menu/menuitem |
54
57
  | L2 | `MenuBar` | `(menus)` or `.from(app.menu_bar)` | declarative menu model or legacy pairs | menubar |
55
58
  | L2 | `Dropdown` | `(label, items:, value:)`; `on_change` | menu-backed | button |
@@ -63,6 +66,8 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
63
66
  | L2 | `Combobox` | `(items, value:, label:, placeholder:, disabled:, matcher:)`; `on_change` | editable, filtered choices with highlighted matches | combobox |
64
67
  | L2 | `MultiSelect` | `(items, value:, label:, disabled:)`; `on_change` | multiple selected badges | listbox |
65
68
  | L2 | `DatePicker` | `(value, min:, max:, label:, disabled:)`; `on_change` | ISO date, day/week keyboard steps | combobox |
69
+ | L2 | `Calendar` | `(value:, min:, max:, range:, week_start:, month_names:)`; `on_change` | arrow keys move days/weeks, Page keys move months, Enter selects | grid/gridcell |
70
+ | L2 | `DateRangePicker` | `(value:, min:, max:, week_start:, month_names:)`; `on_change` | Calendar-backed start/end selection | combobox |
66
71
  | L2 | `TimePicker` | `(value, step:, label:, disabled:)`; `on_change` | 24-hour time, minute/hour keyboard steps | combobox |
67
72
  | L2 | `ColorPicker` | `(value, label:, swatches:, disabled:)`; `on_change` | hex input and swatches | combobox |
68
73
  | L3 | `Tabs` | `(items, selected:)`; `on_change` | arrows/Home/End | tab/tabpanel |
@@ -82,11 +87,14 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
82
87
  | L3 | `SplitPane` | `(first, second, orientation:, ratio:, min:, max:)`; `on_change` | horizontal/vertical, draggable separator | group/separator |
83
88
  | L3 | `PaneGrid` | `(panes, columns:, rows:, divider_size:, minimum:, keyboard_step:)`; `replace`, `on_resize` | arbitrary resizable grid, stable pane IDs | group/separator |
84
89
  | L3 | `Resizable` | `(content, width:, height:, min_width:, min_height:, max_width:, max_height:)`; `on_resize` | drag or keyboard resize | group/separator |
90
+ | L3 | `ZoomPanView` | `(content, zoom:, min_zoom:, max_zoom:)`; `fit`, `zoom_to`, `view_to_content` | drag to pan, pinch or Ctrl+wheel to zoom, `+`/`-`/`0` when focused | group |
85
91
  | L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
92
+ | L3 | `DockWorkspace` | `(layout, render:)`; `on_layout_change`, `on_detach`; `DockLayout.to_h`/`.from_h` | drag tabs to move/split, arrows select, Alt+arrows move, Ctrl+Shift+arrows split, Ctrl+Shift+D requests detach | tablist/tab/tabpanel/separator |
86
93
  | L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
87
94
  | L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit`, `on_copy`, `on_paste` | virtual rows, sorting, resizing, editing, typed clipboard hooks | table/row/cell |
88
95
  | L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize`, `on_copy`, `on_paste` | two-axis virtualization, frozen panes, visible-cell resize/fill and typed clipboard hooks | table |
89
96
  | L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
97
+ | L4 | `PropertyGrid` | `(schema, values, height:, row_height:)`; `on_change`, `set` | typed existing controls, `Validation`, virtual rows | table/row/cell |
90
98
  | L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
91
99
  | L5 | `LineChart`, `BarChart`, `StackedBarChart`, `AreaChart` | `(series, width:, height:, colors:, label:)`; `AreaChart(stacked:)` | shared axes, ticks, grid lines, color-keyed legend, tooltip | image |
92
100
  | L5 | `PieChart`, `DonutChart` | `(data, width:, height:, colors:, label:)` | slices, shared color-keyed legend, tooltip | image |
@@ -101,6 +109,28 @@ All input components are keyboard operable. Disabled controls remain visible but
101
109
  removed from focus traversal. Overlay components close on Esc; modal overlays restore
102
110
  the previous focus. See [TUI](tui.md) for terminal representations.
103
111
 
112
+ `Calendar` and `DateRangePicker` use Ruby's `Date` and ISO 8601 strings. Pass
113
+ `week_start: 0..6` (Sunday is 0) and twelve `month_names:` to localize the grid
114
+ without an i18n dependency. A range picker returns a two-element array of dates;
115
+ its second value is `nil` while the user is choosing the end. `HoverCard` accepts
116
+ an element/component as its anchor to open on pointer hover or keyboard focus, or
117
+ a `Point`/`Bounds` for positioned use. Its delays follow the window's injected clock.
118
+
119
+ `ZoomPanView` uses content-local coordinates for `zoom_to` and `view_to_content`.
120
+ Call `fit` after the first layout; it scales the content into the current viewport.
121
+ Native macOS pinch emits `Input::Magnify`, while Ctrl+wheel provides a desktop fallback.
122
+
123
+ `DockLayout` is a validated tree of tab groups and horizontal/vertical splits with
124
+ stable string IDs. Use `DockLayout.tabs` and `.split` to build it, and persist
125
+ `layout.to_h` in the application; `DockLayout.from_h` restores it. `DockWorkspace`
126
+ passes the active panel ID to `render:` and emits a new layout on tab changes.
127
+ `on_detach` is a notification only; the application decides whether to open a
128
+ window and remove the panel. A pointer drop in the outer 20% of a group splits it.
129
+ `PropertyGrid` schema entries use `key`, optional `label`, `type` (`text`,
130
+ `textarea`, `number`, `boolean`, `select`, `color`, `date`, `time`), `options` for
131
+ select, and an optional `Validation`. Invalid edits remain in the control while
132
+ the last valid value is retained. Both components expose terminal fallbacks.
133
+
104
134
  `Combobox` and `CommandPalette` default to case-insensitive substring matching in
105
135
  input order. Pass `matcher:` to either component, or set a default with
106
136
  `Zaniah.configure { |config| config.matcher = matcher }`. A matcher implements
Binary file
Binary file
data/docs/native.md CHANGED
@@ -70,6 +70,41 @@ applications. macOS also preserves arbitrary MIME types under reversible
70
70
  Wayland use their native format mechanisms. Native clipboard exchange still
71
71
  depends on a running desktop session and should be checked on each target OS.
72
72
 
73
+ ## Typed drag and drop
74
+
75
+ An element can declare the MIME types it accepts and optionally offer a drag
76
+ source. `on_drag_over` returns `:copy`, `:move`, `:link`, or `:none`; the chosen
77
+ operation must be offered by the source. A drop carries `Clipboard::Content`,
78
+ its logical window position, and the operation. File drops also emit the older
79
+ `Input::FileDrop` event so existing listeners continue to work.
80
+ Offering `:move` requires `on_move:` to remove the source data; it runs once
81
+ after a successful move, or when an X11 target acknowledges the `DELETE` selection.
82
+
83
+ ```ruby
84
+ target.on_drop(types: ["image/png", "text/plain"]) do |drop, _cx|
85
+ insert(drop.content)
86
+ end
87
+ target.on_drag_over { |drag, _cx| drag.accepts?("image/png") ? :copy : :none }
88
+ source.draggable do |_mouse, _cx|
89
+ Zaniah::DragData.new(items: [Zaniah::Clipboard::Item.new("text/plain" => "hello")], operations: [:copy])
90
+ end
91
+ ```
92
+
93
+ Headless records the source in `drag_history`; tests may call `drag_over` and
94
+ `complete_drag` to deliver it deterministically. macOS uses AppKit dragging
95
+ sessions; X11 uses XDND (Ctrl requests copy, Shift move, Ctrl+Shift link when
96
+ offered); Wayland uses `wl_data_device`. A target needs `on_drop` and a matching
97
+ MIME type before it can accept a drag. Wayland cannot offer
98
+ link-only drags because its standard data-device action set has only copy and
99
+ move. All desktop protocols depend on another application accepting the MIME
100
+ type and operation. Windows currently preserves file drops through
101
+ `WM_DROPFILES` compatibility and also uses OLE `IDataObject`/`IDropTarget` and
102
+ `IDropSource` for typed data and native drag sources. A preview image is used
103
+ on macOS; Windows, X11, and Wayland currently use the native default cursor
104
+ without a custom preview. TUI has no cross-process drag protocol and only the
105
+ in-process Headless test path.
106
+ X11 XDND proxy windows are not supported.
107
+
73
108
  ## Window state and decorations
74
109
 
75
110
  `window.frame` uses logical screen coordinates; `window.state.to_h` can be saved
data/docs/tui.md CHANGED
@@ -8,25 +8,32 @@ tests and non-window integrations.
8
8
  | --- | --- |
9
9
  | Label, Badge, Avatar | text, `[badge]`, `(initials)` |
10
10
  | Kbd | textual shortcut, such as `Ctrl+Shift+P`; multiple strokes are space-separated |
11
+ | Alert | persistent `! title: message`; dismissed alerts disappear, live alerts announce through accessibility |
11
12
  | Icon | Unicode check/close/search/menu/info/warning symbol; unlabeled unknown icons are omitted |
12
13
  | Divider, Card | `─`/`│` and box-drawing characters |
13
14
  | Skeleton, Spinner, ProgressBar, Meter | shade cells, `◌`, or a ten-cell progress track; animation jumps to its terminal state |
14
15
  | Button, IconButton, ToggleButton | `[ label ]`; disabled uses `( label )` |
15
16
  | Checkbox, Radio, Switch | `[x]`, `(o)`, `[on ]` |
17
+ | SegmentedControl | `[A\|(B)\|C]`, with the selected segment in parentheses; arrows move selection |
16
18
  | Slider, RangeSlider | ten-cell track and numeric value/range |
17
19
  | Text inputs | `[value]`; password values are masked and validation errors have `!` |
18
20
  | Select, Combobox, MultiSelect | labeled brackets, filtered menu marker, or comma-separated selected values |
19
21
  | DatePicker, TimePicker, ColorPicker | labeled ISO date, 24-hour time, or hex color in brackets |
22
+ | Calendar, DateRangePicker | month grid with selected day in `[dd]`; picker shows `[start – end]`, with `…` for an unfinished range |
20
23
  | Tooltip, Popover, Menu, MenuBar, Dropdown | status text or a box/menu with `>` selection marker; nested menus expose a Back row |
24
+ | HoverCard | content text while open; hidden while closed; focus uses the same open delay as hover |
21
25
  | Tabs, Accordion, Collapsible | selected tab in brackets and `[+]`/`[-]` disclosure markers |
22
26
  | ScrollView, Scrollbar | clipped child content and `│`/`─` track |
23
27
  | Breadcrumb, Pagination, bars | slash-separated path, page status, space-separated content |
24
28
  | Modal, Dialog, Drawer, CommandPalette | box-drawing overlay; palette filters results and supports Up/Down/Enter; focus stays inside until dismissed |
25
29
  | SplitPane, PaneGrid, Resizable, DockPanel | `│`/`─` separators, resize corner, or ordered dock regions |
30
+ | ZoomPanView | zoom percentage and content text; focused `+`/`-`/`0` changes zoom, but pointer panning has no cell equivalent |
31
+ | DockWorkspace | active tab in parentheses inside `[tabs]`, split groups separated by `│` or `───`; arrows select, Alt+arrows move and Ctrl+Shift+arrows split without dragging |
26
32
  | ListView | visible rows with `>` on the selected item |
27
33
  | Toast | live status text |
28
34
  | Table, DataGrid | header and visible rows separated with `|` |
29
35
  | TreeView | indentation with `▸`/`▾` expansion markers |
36
+ | PropertyGrid | up to 20 `label: value` rows, with `!` and the validation message on errors; Up/Down/Page keys move the virtual row selection |
30
37
  | Sparkline and all chart types | eight-level Unicode sparkline summary |
31
38
  | Form, FormField | labeled values, `!` errors, and a submit button |
32
39
  | CodeEditor | numbered logical lines; wrap, syntax colors, and inline IME underline degrade to plain cells |
data/examples/gallery.rb CHANGED
@@ -57,19 +57,43 @@ module Gallery
57
57
  .child(Zaniah::Div.new.flex_row.gap(12).children([
58
58
  Zaniah::UI::DatePicker.new("2026-09-13"), Zaniah::UI::TimePicker.new("14:30"),
59
59
  Zaniah::UI::ColorPicker.new("#2563eb")]))
60
+ feedback_dates = Zaniah::Div.new.w_full.gap(12)
61
+ .child(Zaniah::Div.new.flex_row.items_center.gap(16).children([
62
+ Zaniah::UI::SegmentedControl.new([["Day", :day], ["Week", :week], ["Month", :month]], value: :week),
63
+ Zaniah::UI::DateRangePicker.new(value: ["2026-09-01", "2026-09-30"])]))
64
+ .child(Zaniah::UI::Alert.new("Export complete", message: "Your file is ready", variant: :success, dismissible: true))
65
+ .child(Zaniah::UI::Calendar.new(value: "2026-09-24", week_start: 1))
66
+ dock_layout = Zaniah::UI::DockLayout.split(id: :work, orientation: :horizontal, ratio: 0.42,
67
+ first: Zaniah::UI::DockLayout.tabs(id: :left, panels: %i[Files Search]),
68
+ second: Zaniah::UI::DockLayout.tabs(id: :right, panels: %i[Editor Preview]))
69
+ property_schema = [
70
+ {key: :title, label: "Title", type: :text},
71
+ {key: :count, label: "Count", type: :number},
72
+ {key: :visible, label: "Visible", type: :boolean}
73
+ ]
60
74
  workspaces = Zaniah::Div.new.w_full.gap(12)
61
75
  .child(Zaniah::Div.new.flex_row.gap(12).children([
62
76
  Zaniah::UI::SplitPane.new(Zaniah::UI::Label.new("Left pane"), Zaniah::UI::Label.new("Right pane")).w(300).h(100),
63
77
  Zaniah::UI::Resizable.new(Zaniah::UI::Label.new("Resize me"), width: 180, height: 100),
64
78
  Zaniah::UI::ListView.new(%w[Alpha Beta Gamma Delta], height: 100, selected: 1).w(180)]))
65
- .child(Zaniah::UI::DockPanel.new(
66
- center: Zaniah::UI::Label.new("Editor"), top: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Top", size: :sm)),
67
- left: Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Files", size: :sm), width: 90),
68
- bottom: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm))).w(420).h(120))
79
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
80
+ Zaniah::UI::DockPanel.new(
81
+ center: Zaniah::UI::Label.new("Editor"), top: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Top", size: :sm)),
82
+ left: Zaniah::UI::Sidebar.new(Zaniah::UI::Label.new("Files", size: :sm), width: 90),
83
+ bottom: Zaniah::UI::StatusBar.new(Zaniah::UI::Label.new("Ready", size: :sm))).w(420).h(120),
84
+ Zaniah::UI::ZoomPanView.new(Zaniah::Div.new.w(110).h(70).p(8).bg("#2563eb")
85
+ .child(Zaniah::UI::Label.new("Zoom / pan", size: :sm)), zoom: 1.3).w(180).h(120)
86
+ ]))
87
+ .child(Zaniah::Div.new.flex_row.gap(12).children([
88
+ Zaniah::UI::DockWorkspace.new(dock_layout,
89
+ render: ->(id) { Zaniah::Div.new.p(10).child(Zaniah::UI::Label.new("#{id} panel")) }).w(440).h(190),
90
+ Zaniah::UI::PropertyGrid.new(property_schema,
91
+ {title: "Workspace", count: 3, visible: true}, height: 190).w(340)
92
+ ]))
69
93
  editors = Zaniah::Div.new.w_full.flex_row.gap(12).children([
70
94
  Zaniah::UI::CodeEditor.new("def hello\n :world\nend\n", language: :ruby).w(390).h(150),
71
95
  Zaniah::UI::RichText.new([{text: "Rich ", size: 20}, {text: "text", color: "#2563eb", size: 20}]).w(260)])
72
- Zaniah::Div.new.h(2480).p(16).gap(12)
96
+ Zaniah::Div.new.h(3200).p(16).gap(12)
73
97
  .child(section("Foundation", foundation, height: 180))
74
98
  .child(section("Actions",
75
99
  Zaniah::UI::Button.new("Primary").icon(:check).on_click(&clicks),
@@ -94,7 +118,8 @@ module Gallery
94
118
  .child(section("Variants", variants, height: 285))
95
119
  .child(section("Data", data, height: 440))
96
120
  .child(section("Choice and pickers", choices, height: 210))
97
- .child(section("Workspace layout", workspaces, height: 300))
121
+ .child(section("Feedback and dates", feedback_dates, height: 410))
122
+ .child(section("Workspace layout", workspaces, height: 530))
98
123
  .child(section("Editors", editors, height: 210))
99
124
  .child(section("Forms", Zaniah::UI::Form.new.field(name: :email, label: "Release email", value: "ruby@example.com", validation: validation).w(320), height: 150))
100
125
  end
@@ -110,6 +135,7 @@ module Gallery
110
135
  when /\Atoast-(info|success|warning|danger)\z/ then Zaniah::UI::Toast.new("Saved", variant: Regexp.last_match(1).to_sym)
111
136
  when "tooltip" then Zaniah::UI::Tooltip.new("Helpful text", anchor: anchor)
112
137
  when "popover" then Zaniah::UI::Popover.new(content, anchor: anchor)
138
+ when "hover-card" then Zaniah::UI::HoverCard.new(Zaniah::UI::Label.new("Keyboard shortcuts"), anchor: anchor).open
113
139
  when "menu" then Zaniah::UI::Menu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
114
140
  when "context-menu" then Zaniah::UI::ContextMenu.new(items, anchor: Zaniah::Point.new(anchor.x, anchor.bottom))
115
141
  when "modal" then Zaniah::UI::Modal.new(content, title: "Modal")
@@ -37,4 +37,27 @@ module Zaniah
37
37
  class Content < Item
38
38
  end
39
39
  end
40
+
41
+ class DragData
42
+ OPERATIONS = %i[copy move link].freeze
43
+ attr_reader :items, :image, :operations, :on_move
44
+
45
+ def initialize(items:, image: nil, operations: [:copy], on_move: nil)
46
+ raise TypeError, "drag items must be an Array of Clipboard::Item" unless items.is_a?(Array) && !items.empty? && items.all? { |item| item.is_a?(Clipboard::Item) }
47
+ raise ArgumentError, "invalid drag operations" unless operations.is_a?(Array) && !operations.empty? && operations.all? { |operation| OPERATIONS.include?(operation) }
48
+ raise ArgumentError, "move operations require an on_move callback" if operations.include?(:move) && !on_move.respond_to?(:call)
49
+ @items, @image, @operations, @on_move = items.dup.freeze, image, operations.uniq.freeze, on_move
50
+ freeze
51
+ end
52
+
53
+ def types = @items.flat_map(&:types).uniq.freeze
54
+ def content(types: self.types)
55
+ raise TypeError, "drag types must be an Array" unless types.is_a?(Array)
56
+ formats = types.each_with_object({}) do |type, result|
57
+ item = @items.find { |entry| entry.types.include?(type) }
58
+ result[type] = item.fetch(type) if item
59
+ end
60
+ Clipboard::Content.new(formats)
61
+ end
62
+ end
40
63
  end
@@ -105,9 +105,26 @@ module Zaniah
105
105
  define_method(method) { |value| style(**{property => value}) }
106
106
  end
107
107
 
108
- %i[click hover drag scroll_wheel mouse_down mouse_up].each do |kind|
108
+ %i[click hover drag scroll_wheel magnify mouse_down mouse_up].each do |kind|
109
109
  define_method("on_#{kind}") { |&block| @handlers[kind] = block; self }
110
110
  end
111
+ def on_drop(types:, &block)
112
+ raise TypeError, "drop types must be an Array of MIME names" unless types.is_a?(Array) && !types.empty? && types.all? { |type| type.is_a?(String) && type.match?(%r{\A[^\s/]+/[^\s/]+\z}) }
113
+ raise ArgumentError, "drop handler required" unless block
114
+ @drop_types, @handlers[:drop] = types.uniq.freeze, block
115
+ self
116
+ end
117
+ def on_drag_over(&block)
118
+ raise ArgumentError, "drag-over handler required" unless block
119
+ @handlers[:drag_over] = block
120
+ self
121
+ end
122
+ def draggable(&block)
123
+ raise ArgumentError, "drag source required" unless block
124
+ @drag_source = block
125
+ self
126
+ end
127
+ def drop_types = @drop_types || []
111
128
 
112
129
  def request_layout(cx)
113
130
  @state = cx.state(@key, &@state_initializer) if @key && @state_initializer
@@ -159,8 +176,31 @@ module Zaniah
159
176
  @focus_handle.focusable = !@static_flags.include?(:disabled)
160
177
  cx.dispatcher.register_focus(@focus_handle)
161
178
  end
162
- unless @handlers.empty? && !@tooltip && !@context_menu && !@style_set.interactive? && !@focus_handle && !@window_drag_region && !@window_control_kind
179
+ unless @handlers.empty? && !@tooltip && !@context_menu && !@style_set.interactive? && !@focus_handle && !@window_drag_region && !@window_control_kind && !@drag_source
163
180
  cx.dispatcher.hit(bounds, owner: self) do |event|
181
+ if @pending_drag && event.is_a?(Input::MouseMove)
182
+ original = @pending_drag
183
+ if (event.position.x - original.position.x).abs >= 4 || (event.position.y - original.position.y).abs >= 4
184
+ @pending_drag = nil
185
+ cx.dispatcher.release_mouse_capture
186
+ cx.window.begin_drag(@drag_source.call(original, cx), event: original)
187
+ next true
188
+ end
189
+ end
190
+ @pending_drag = nil if event.is_a?(Input::MouseUp)
191
+ if event.is_a?(Input::DragOver)
192
+ next false unless @handlers[:drop] && @drop_types.any? { |type| event.accepts?(type) }
193
+ operation = @handlers[:drag_over]&.call(event, cx)
194
+ operation ||= event.operations.first
195
+ operation = :none unless event.operations.include?(operation)
196
+ event.operation = operation
197
+ next operation != :none
198
+ end
199
+ if event.is_a?(Input::DataDrop)
200
+ next false unless @handlers[:drop] && @drop_types.any? { |type| event.content.types.include?(type) }
201
+ @handlers[:drop].call(event, cx)
202
+ next true
203
+ end
164
204
  if event.is_a?(Input::MouseDown) && event.button == :right && @context_menu
165
205
  cx.window.context_menu(@context_menu, position: event.position)
166
206
  next true
@@ -172,11 +212,16 @@ module Zaniah
172
212
  when Input::MouseMove then @dragging && @handlers.key?(:drag) ? :drag : :hover
173
213
  when Input::MouseUp then :mouse_up
174
214
  when Input::ScrollWheel then :scroll_wheel
215
+ when Input::Magnify then :magnify
175
216
  end
176
217
  handler = @handlers[kind]
177
218
  @dragging = true if event.is_a?(Input::MouseDown) && @handlers[:drag]
178
219
  @dragging = false if event.is_a?(Input::MouseUp)
179
220
  handler&.call(event, cx)
221
+ if event.is_a?(Input::MouseDown) && event.button == :left && @drag_source
222
+ @pending_drag = event
223
+ next :capture
224
+ end
180
225
  @dragging && event.is_a?(Input::MouseDown) ? :capture : !!handler || !!(@focus_handle && event.is_a?(Input::MouseDown)) || !!(@tooltip && event.is_a?(Input::MouseMove))
181
226
  end
182
227
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ DataDrop = Data.define(:content, :position, :operation)
6
+
7
+ class DragOver
8
+ OPERATIONS = %i[copy move link none].freeze
9
+ attr_reader :types, :position, :operations, :operation
10
+
11
+ def initialize(types:, position:, operations: [:copy])
12
+ raise TypeError, "drag types must be an Array of MIME names" unless types.is_a?(Array) && types.all? { |type| type.is_a?(String) }
13
+ raise TypeError, "drag position must be a Point" unless position.is_a?(Point)
14
+ raise ArgumentError, "invalid drag operations" unless operations.is_a?(Array) && operations.all? { |operation| OPERATIONS.include?(operation) && operation != :none }
15
+ @types, @position, @operations, @operation = types.uniq.freeze, position, operations.uniq.freeze, :none
16
+ end
17
+
18
+ def accepts?(type) = @types.include?(type)
19
+ def operation=(value)
20
+ raise ArgumentError, "unsupported drag operation #{value.inspect}" unless value == :none || @operations.include?(value)
21
+ @operation = value
22
+ end
23
+ end
24
+ end
25
+ end
@@ -119,6 +119,7 @@ module Zaniah
119
119
  @focus_tree.clear
120
120
  @transforms.replace([Transform.identity])
121
121
  end
122
+ def release_mouse_capture = @capture = nil
122
123
  def hits = @hits.map { |bounds, _, owner, _, clip| Hit.new(clip ? bounds.intersect(clip) : bounds, owner) }.freeze
123
124
  def hit_regions = @hits.map { |bounds, _, owner, transform, clip| HitRegion.new(bounds, owner, transform, clip) }.freeze
124
125
 
@@ -91,6 +91,27 @@ module Zaniah
91
91
  .bind("home", :first, context: "in_grid")
92
92
  .bind("end", :last, context: "in_grid")
93
93
  .bind("enter", :activate, context: "in_grid")
94
+ .bind("left", :previous_day, context: "in_calendar")
95
+ .bind("right", :next_day, context: "in_calendar")
96
+ .bind("up", :previous_week, context: "in_calendar")
97
+ .bind("down", :next_week, context: "in_calendar")
98
+ .bind("pageup", :previous_month, context: "in_calendar")
99
+ .bind("pagedown", :next_month, context: "in_calendar")
100
+ .bind("left", :previous_option, context: "in_dock")
101
+ .bind("right", :next_option, context: "in_dock")
102
+ .bind("home", :first, context: "in_dock")
103
+ .bind("end", :last, context: "in_dock")
104
+ .bind("alt-left", :move_previous, context: "in_dock")
105
+ .bind("alt-right", :move_next, context: "in_dock")
106
+ .bind("ctrl-shift-left", :split_left, context: "in_dock")
107
+ .bind("ctrl-shift-right", :split_right, context: "in_dock")
108
+ .bind("ctrl-shift-up", :split_top, context: "in_dock")
109
+ .bind("ctrl-shift-down", :split_bottom, context: "in_dock")
110
+ .bind("ctrl-shift-d", :detach, context: "in_dock")
111
+ .bind("+", :zoom_in, context: "in_zoom_pan && !in_text_field")
112
+ .bind("shift-=", :zoom_in, context: "in_zoom_pan && !in_text_field")
113
+ .bind("-", :zoom_out, context: "in_zoom_pan && !in_text_field")
114
+ .bind("0", :zoom_reset, context: "in_zoom_pan && !in_text_field")
94
115
  .bind("up", :previous_option, context: "in_combobox")
95
116
  .bind("down", :next_option, context: "in_combobox")
96
117
  .bind("home", :first, context: "in_combobox")
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zaniah
4
+ module Input
5
+ Magnify = Data.define(:position, :delta, :phase)
6
+ end
7
+ end
data/lib/zaniah/input.rb CHANGED
@@ -7,8 +7,10 @@ require_relative "input/mouse_down"
7
7
  require_relative "input/mouse_up"
8
8
  require_relative "input/mouse_move"
9
9
  require_relative "input/scroll_wheel"
10
+ require_relative "input/magnify"
10
11
  require_relative "input/composition"
11
12
  require_relative "input/file_drop"
13
+ require_relative "input/data_drop"
12
14
  require_relative "input/keystroke"
13
15
  require_relative "input/context_predicate"
14
16
  require_relative "input/keymap"
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module Zaniah
4
6
  module Platform
5
7
  module Headless
@@ -9,7 +11,7 @@ module Zaniah
9
11
  include Appearance
10
12
  DEFAULT_CLEAR = "#181b20"
11
13
 
12
- attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs, :pointer_position, :cursor_style, :animator, :clock, :accessibility_tree, :accessibility_revision, :frame_stats, :last_root, :frame_number, :decorations, :transparent, :min_size, :resizable, :traffic_lights
14
+ attr_reader :content_size, :scene, :device, :dispatcher, :scale_factor, :text_runs, :pointer_position, :cursor_style, :animator, :clock, :accessibility_tree, :accessibility_revision, :frame_stats, :last_root, :frame_number, :decorations, :transparent, :min_size, :resizable, :traffic_lights, :drag_data, :drag_result, :drag_history
13
15
  attr_accessor :text_system, :ime_state, :title, :app, :devtools
14
16
 
15
17
  def initialize(width: 800, height: 600, title: "Zaniah UI", scale_factor: 1,
@@ -34,6 +36,7 @@ module Zaniah
34
36
  @window_frame = Bounds.new(0, 0, width, height)
35
37
  @maximized = @minimized = @fullscreen = @always_on_top = false
36
38
  @dirty, @closed, @pointer_down, @cursor_style = true, false, false, :arrow
39
+ @drag_history = []
37
40
  end
38
41
 
39
42
  def on_input(&block) = @on_input = block
@@ -80,6 +83,70 @@ module Zaniah
80
83
  write_clipboard([Clipboard::Item.new("text/plain" => value)])
81
84
  value
82
85
  end
86
+ def begin_drag(data, event: nil)
87
+ raise TypeError, "drag source must return DragData" unless data.is_a?(DragData)
88
+ @drag_data, @drag_result = data, nil
89
+ @drag_move_committed = false
90
+ @drag_history << [:begin, data, event].freeze
91
+ data
92
+ end
93
+
94
+ def drag_over(types:, position:, operations: [:copy])
95
+ event = Input::DragOver.new(types: types, position: position, operations: operations)
96
+ @dispatcher.mouse(event)
97
+ event.operation
98
+ end
99
+
100
+ def deliver_drop(content:, position:, operation: :copy, paths: nil)
101
+ raise TypeError, "drop content must be Clipboard::Content" unless content.is_a?(Clipboard::Content)
102
+ raise TypeError, "drop position must be a Point" unless position.is_a?(Point)
103
+ raise ArgumentError, "invalid drop operation" unless DragData::OPERATIONS.include?(operation)
104
+ handled = input(Input::DataDrop.new(content, position, operation))
105
+ paths ||= file_paths_in_drop(content)
106
+ input(Input::FileDrop.new(paths.freeze, position)) unless paths.empty?
107
+ handled ? operation : :none
108
+ end
109
+
110
+ def complete_drag(position:, operation: nil, content: nil)
111
+ raise Error, "no drag is active" unless @drag_data
112
+ data = @drag_data
113
+ operation ||= drag_over(types: data.types, position: position, operations: data.operations)
114
+ raise ArgumentError, "drag operation was not offered" unless operation == :none || data.operations.include?(operation)
115
+ operation = deliver_drop(content: content || data.content, position: position, operation: operation) unless operation == :none
116
+ finish_drag_source(operation, position: position)
117
+ end
118
+
119
+ def finish_drag_source(operation, position: nil)
120
+ raise ArgumentError, "drag operation was not offered" unless operation == :none || @drag_data&.operations&.include?(operation)
121
+ operation = :none if operation == :move && !commit_drag_move
122
+ @drag_result = operation
123
+ @drag_history << [:complete, operation, position].freeze
124
+ @drag_data = nil
125
+ operation
126
+ end
127
+
128
+ def commit_drag_move
129
+ return false unless @drag_data&.on_move
130
+ return true if @drag_move_committed
131
+ @drag_data.on_move.call
132
+ @drag_move_committed = true
133
+ rescue StandardError
134
+ false
135
+ end
136
+
137
+ def file_paths_in_drop(content)
138
+ source = content.formats["text/uri-list"]
139
+ return [] unless source
140
+ source.each_line.filter_map do |line|
141
+ next if line.start_with?("#") || line.strip.empty?
142
+ uri = URI.parse(line.strip)
143
+ next unless uri.scheme == "file" && [nil, "", "localhost"].include?(uri.host) && uri.query.nil? && uri.fragment.nil?
144
+ path = URI::DEFAULT_PARSER.unescape(uri.path).force_encoding(Encoding::UTF_8)
145
+ path if path.start_with?("/") && path.valid_encoding? && !path.include?("\0")
146
+ rescue URI::InvalidURIError
147
+ nil
148
+ end
149
+ end
83
150
  def set_cursor(style)
84
151
  raise ArgumentError, "unknown cursor #{style}" unless %i[arrow text pointer crosshair resize_horizontal resize_vertical].include?(style)
85
152
  return style if @cursor_style == style
@@ -224,7 +291,7 @@ module Zaniah
224
291
  @tooltip = nil if event.is_a?(Input::MouseDown) || event.is_a?(Input::KeyDown)
225
292
  @on_input&.call(event)
226
293
  @devtools&.handle_input(event)
227
- if event.respond_to?(:position)
294
+ handled = if event.respond_to?(:position)
228
295
  @dispatcher.mouse(event)
229
296
  elsif event.is_a?(Input::KeyDown)
230
297
  @dispatcher.key(event.keystroke)
@@ -233,6 +300,7 @@ module Zaniah
233
300
  end
234
301
  @tooltip = nil if event.is_a?(Input::MouseMove) && !@tooltip_offered
235
302
  request_frame
303
+ handled
236
304
  end
237
305
 
238
306
  def offer_tooltip(text, position:, delay: 0.5)