zaniah 0.6.1 → 0.6.3
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +9 -0
- data/docs/adr/012-productivity-components.md +25 -0
- data/docs/components.md +16 -1
- data/docs/images.md +18 -0
- data/docs/layout.md +46 -0
- data/docs/native.md +9 -0
- data/docs/text.md +22 -0
- data/lib/zaniah/gif.rb +189 -0
- data/lib/zaniah/image.rb +30 -2
- data/lib/zaniah/input/keymap.rb +12 -0
- data/lib/zaniah/jpeg.rb +431 -0
- data/lib/zaniah/list/height_index.rb +3 -2
- data/lib/zaniah/platform/headless/window.rb +8 -0
- data/lib/zaniah/platform/linux/wayland_window.rb +14 -0
- data/lib/zaniah/platform/linux/window.rb +12 -0
- data/lib/zaniah/platform/mac/window.rb +15 -0
- data/lib/zaniah/platform/windows/window.rb +15 -0
- data/lib/zaniah/ui/charts.rb +299 -0
- data/lib/zaniah/ui/editors.rb +303 -13
- data/lib/zaniah/ui/grid.rb +492 -0
- data/lib/zaniah/ui/rich_text_surface.rb +302 -0
- data/lib/zaniah/ui/terminal_view.rb +129 -0
- data/lib/zaniah/ui.rb +3 -0
- data/lib/zaniah/version.rb +1 -1
- data/lib/zaniah.rb +2 -0
- data/sig/elements.rbs +2 -0
- data/sig/native.rbs +7 -0
- data/sig/platform.rbs +2 -0
- data/sig/ui.rbs +67 -2
- data/sig/ui_terminal_view.rbs +15 -0
- data/sig/zaniah.rbs +14 -0
- metadata +18 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d5d0895db7cb838e553fc0e7de6c20df016c9c56d3938cfaa94dc99cc47f2ab2
|
|
4
|
+
data.tar.gz: ef786f1e8ee6e87da921f317bd7901d97553e1fbe9e3e6969bb5df588a300754
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e784d7df56037b50a88e4e8e4fdccbba5f9544b5e7d56b88480f78468627210919f8a87093ea6d461ba56355bb179c7fbe96d8d52d7990a6107d96ce73fc9872
|
|
7
|
+
data.tar.gz: e9e9726a52da9e277d6ebc56f6587a08ca0550e4fbfd83fbb25ef8fe75b58cb8badc976af92b179e530342d9da96aeda8f91332d395f12c77963bfee1c808f19
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.3 — 2026-09-23
|
|
4
|
+
|
|
5
|
+
- Render supplied text grid cells directly as cell surfaces, avoiding an extra wrapper per visible cell.
|
|
6
|
+
- Add a two-axis virtualized UI grid with frozen panes, range selection, variable sizing, resize, and fill callbacks.
|
|
7
|
+
- Preserve explicit grid row/column sizes across viewport rebuilds and support validated, batched row/column hide and unhide.
|
|
8
|
+
- Add editable rich text with style spans, paragraph alignment/lists, selection, caret, and IME composition.
|
|
9
|
+
- Decode baseline 8-bit JPEG images, including Exif orientation, grayscale, RGB, and common YCbCr sampling modes; detect JPEG images from bytes.
|
|
10
|
+
- Allow Alhena 0.3 while retaining compatibility with the existing Alhena 0.2 API.
|
|
11
|
+
|
|
12
|
+
## 0.6.2 — 2026-09-21
|
|
13
|
+
|
|
14
|
+
- Add GIF decoding and a damage-aware terminal grid view.
|
|
15
|
+
|
|
3
16
|
## 0.6.1 — 2026-09-21
|
|
4
17
|
|
|
5
18
|
- Add a deterministic headless demo image and regeneration task.
|
data/README.md
CHANGED
|
@@ -42,7 +42,9 @@ the editor, providing the native rendering layer for its UI.
|
|
|
42
42
|
- Scroll views, inertial input, and uniform or variable-height virtual lists
|
|
43
43
|
- Stable-ID pointer and keyboard reordering without materializing virtual collections
|
|
44
44
|
- Stable-ID split-pane grids with fixed, fractional, and minmax tracks
|
|
45
|
+
- Two-axis virtual data grids with frozen panes, range selection, and editable rich text with IME
|
|
45
46
|
- OpenType shaping, font fallback, Japanese wrapping, text overlays, low-resolution text caching, selection, editing, and IME
|
|
47
|
+
- PNG, GIF, and baseline JPEG image decoding with Exif orientation
|
|
46
48
|
- Themes, state styles, keyed animation, springs, and reduced-motion support
|
|
47
49
|
- Opt-in controls, overlays, tables, externally completable lazy virtual trees, charts, forms, and terminal fallbacks
|
|
48
50
|
- Spatial keyboard focus and a stable-ID, event-diffed cross-platform accessibility tree
|
|
@@ -114,10 +116,17 @@ For an interactive window, choose `backend: :mac`, `:linux`, `:windows`, or
|
|
|
114
116
|
Native backends use the operating system libraries through Ruby's Fiddle. See
|
|
115
117
|
[Native backends](docs/native.md) for platform requirements and checks.
|
|
116
118
|
|
|
119
|
+
Native macOS, Windows, and X11 windows can be moved to a display returned by
|
|
120
|
+
`Zaniah::Platform.displays` with `window.move_to_display(display)`. Headless and
|
|
121
|
+
terminal windows cannot be placed on physical displays. Wayland does not permit
|
|
122
|
+
arbitrary client window placement; its `fullscreen_on(display)` asks the
|
|
123
|
+
compositor to fullscreen on that output, but the compositor may choose otherwise.
|
|
124
|
+
|
|
117
125
|
## Documentation
|
|
118
126
|
|
|
119
127
|
- [Native backends](docs/native.md) — windows, displays, file watching, and terminals
|
|
120
128
|
- [Text system](docs/text.md) — font discovery, shaping, rasterization, and caching
|
|
129
|
+
- [Images](docs/images.md) — PNG, GIF, baseline JPEG, and orientation support
|
|
121
130
|
- [SVG and lists](docs/vector_and_list.md) — static vector icons and virtual lists
|
|
122
131
|
- [Drag-and-drop reordering](docs/drag_drop.md) — stable IDs, virtual targets, and keyboard access
|
|
123
132
|
- [Layout and scrolling](docs/layout.md) — Grid, resizable pane grids, scrolling, sticky positioning, and RTL foundations
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# ADR 012: Keep productivity components separate from existing controls
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-09-23
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
The spreadsheet and presentation applications need sparse two-axis grids and
|
|
9
|
+
inline rich-text editing. `UI::Table` models records by column definitions;
|
|
10
|
+
`List::HeightIndex` indexes one axis; and `UI::TextInputs` edits plain text.
|
|
11
|
+
|
|
12
|
+
## Decision
|
|
13
|
+
|
|
14
|
+
- Use a dedicated virtualized `UI::Grid`; `UI::Table` is not a substitute for
|
|
15
|
+
million-row sparse cell coordinates, range selection, or frozen panes.
|
|
16
|
+
- Reuse `List::HeightIndex` independently for each grid axis rather than
|
|
17
|
+
generalizing it into a second indexing abstraction.
|
|
18
|
+
- Keep `UI::TextInputs` unchanged and implement inline rich text in a separate
|
|
19
|
+
surface so existing plain-text editing behavior remains stable.
|
|
20
|
+
|
|
21
|
+
## Consequences
|
|
22
|
+
|
|
23
|
+
The productivity features stay additive and preserve existing table and text
|
|
24
|
+
input APIs. The grid shares the tested prefix-index implementation while
|
|
25
|
+
maintaining separate row and column state.
|
data/docs/components.md
CHANGED
|
@@ -84,6 +84,7 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
|
|
|
84
84
|
| L3 | `DockPanel` | `(center:, top:, right:, bottom:, left:)` | five-region layout | group |
|
|
85
85
|
| L3 | `ListView` | `(items, height:, row_height:, selected:)`; `on_select` | virtual rows and keyboard selection | list/listitem |
|
|
86
86
|
| L4 | `Table`, `DataGrid` | `(rows, columns:, height:, selection:, row_key:)`; `on_sort`, `on_select`, `on_edit` | virtual rows, sorting, resizing, editing | table/row/cell |
|
|
87
|
+
| L4 | `Grid` | `(rows:, columns:, row_height:, column_width:, frozen_rows:, frozen_columns:)`; `scroll_to`, range `selection`, `on_select`, `on_edit`, `on_fill`, `on_resize` | two-axis virtualization, frozen panes, visible-cell resize/fill callbacks | table |
|
|
87
88
|
| L4 | `TreeView` | `(items, height:, selected:)`; `expand`, `collapse`, `replace`, `replace_children`, `invalidate`, lazy `children` proc | arrows/Home/End | tree/treeitem |
|
|
88
89
|
| L5 | `Sparkline` | `(values, width:, height:, color:, label:)` | line + tooltip | image |
|
|
89
90
|
| L5 | `LineChart`, `BarChart` | `(series, width:, height:, colors:, label:)` | axes, legend, tooltip | image |
|
|
@@ -91,12 +92,26 @@ Zaniah::UI::Button.variants[:variant][:brand] = ->(theme) {
|
|
|
91
92
|
| L5 | `FormField` | `(name, value:, label:, control:, validation:, hint:)` | errors + describedby | group/control/alert |
|
|
92
93
|
| L5 | `Form` | `field`, `on_change`, `on_submit`, `values`, `valid?` | validates before submit | form |
|
|
93
94
|
| L5 | `CodeEditor` | `(value, language:, line_numbers:, read_only:)`; `on_change` | multiline editor with scrolling | textbox |
|
|
94
|
-
| L5 | `RichText` | `(runs, selectable:)` | styled
|
|
95
|
+
| L5 | `RichText` | `(runs, selectable:, editable:)`; `apply`, `insert`, `delete`, `replace`, `paragraph_style` | styled editing, IME, range selection and caret | text/textbox |
|
|
95
96
|
|
|
96
97
|
All input components are keyboard operable. Disabled controls remain visible but are
|
|
97
98
|
removed from focus traversal. Overlay components close on Esc; modal overlays restore
|
|
98
99
|
the previous focus. See [TUI](tui.md) for terminal representations.
|
|
99
100
|
|
|
101
|
+
`UI::RichText` accepts UTF-8 byte ranges at grapheme boundaries. Inline styles are
|
|
102
|
+
`bold`, `italic`, `size`, `color`, `font`, and `link`; paragraphs support start,
|
|
103
|
+
center, end, and non-final-line justification, plus bullet/ordered lists and levels.
|
|
104
|
+
It is read-only by default; set `editable: true` to enable keyboard editing and IME.
|
|
105
|
+
|
|
106
|
+
## Image decoding
|
|
107
|
+
|
|
108
|
+
`Image.from_bytes(bytes)` detects PNG, GIF, and baseline JPEG by signature. Pass
|
|
109
|
+
`format: :png`, `:gif`, or `:jpeg` to choose explicitly. JPEG supports 8-bit
|
|
110
|
+
baseline grayscale and three-component RGB/YCbCr, JFIF frame dimensions, Exif
|
|
111
|
+
orientation 1–8, and 4:4:4, 4:2:2, and 4:2:0 sampling. Progressive, CMYK,
|
|
112
|
+
arithmetic-coded, and multi-scan JPEG files raise `JPEG::Error`; the default
|
|
113
|
+
pixel limit is 16,777,216.
|
|
114
|
+
|
|
100
115
|
Table columns are hashes with `key`, and optional `label`, `width`, `sortable`,
|
|
101
116
|
`resizable`, `editable`, and `render`. Tree items accept hashes containing `id`,
|
|
102
117
|
`label`, and either an array or lazy proc in `children`. A lazy proc receives the
|
data/docs/images.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Images
|
|
2
|
+
|
|
3
|
+
`Zaniah::Image` accepts PNG, GIF, and baseline JPEG data. Paths are read as
|
|
4
|
+
bytes, then the format is selected from its signature; use `from_bytes` when
|
|
5
|
+
the application already has the encoded data:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
image = Zaniah::Image.from_bytes(File.binread("photo.jpg"))
|
|
9
|
+
explicit = Zaniah::Image.from_bytes(bytes, format: :jpeg)
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
JPEG decoding is pure Ruby and returns RGBA pixels. It supports 8-bit baseline
|
|
13
|
+
grayscale, RGB (Adobe transform 0), and YCbCr, including JFIF frame dimensions,
|
|
14
|
+
Exif orientation 1–8, and 4:4:4, 4:2:2, and 4:2:0 sampling. Progressive,
|
|
15
|
+
extended, arithmetic-coded, CMYK, and multi-scan JPEGs are not supported;
|
|
16
|
+
unsupported coding processes raise `Zaniah::JPEG::Error` instead of producing
|
|
17
|
+
an incomplete image. `JPEG.decode` limits images to 16,777,216 pixels by
|
|
18
|
+
default and accepts a different positive `max_pixels` limit explicitly.
|
data/docs/layout.md
CHANGED
|
@@ -37,6 +37,52 @@ stable accessibility IDs, focus state, value, orientation, and direct increment,
|
|
|
37
37
|
decrement, minimum, and maximum actions use the same clamped resize path. Nested
|
|
38
38
|
`PaneGrid` instances compose without owning editor, terminal, or document state.
|
|
39
39
|
|
|
40
|
+
## Two-axis virtual grid
|
|
41
|
+
|
|
42
|
+
`UI::Grid` virtualizes rows and columns independently. It uses estimated row and
|
|
43
|
+
column sizes for a large sheet, calling the size procs only for frozen and
|
|
44
|
+
visible cells. Use `frozen_rows` and `frozen_columns` for headers and key
|
|
45
|
+
columns; cell ranges are represented by half-open integer ranges.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
grid = Zaniah::UI::Grid.new(
|
|
49
|
+
rows: 1_048_576, columns: 16_384,
|
|
50
|
+
row_height: ->(row) { row.zero? ? 32 : 24 },
|
|
51
|
+
column_width: ->(column) { column.zero? ? 180 : 96 },
|
|
52
|
+
frozen_rows: 1, frozen_columns: 1
|
|
53
|
+
) do |row, column, bounds, cx|
|
|
54
|
+
Zaniah::UI::Label.new("#{row},#{column}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
grid.on_select { |areas, _event, _cx| p areas }
|
|
58
|
+
grid.on_edit { |row, column, _event, _cx| edit_cell(row, column) }
|
|
59
|
+
grid.on_fill { |source, destination, _cx| fill_cells(source, destination) }
|
|
60
|
+
grid.on_resize { |axis, index, size, _cx| save_size(axis, index, size) }
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The renderer returns an Element, String, Numeric, or `nil` for each visible
|
|
64
|
+
cell. `bounds` is local to the grid viewport. Set `estimated_row_height` or
|
|
65
|
+
`estimated_column_width` when proc-based sizes differ substantially from the
|
|
66
|
+
defaults. `scroll_to(row:, column:)` ensures the target cell is visible.
|
|
67
|
+
Resizing is available by dragging a visible cell's lower/right edge or by
|
|
68
|
+
calling `set_row_height` / `set_column_width`; explicit size overrides persist
|
|
69
|
+
when the virtualized viewport is rebuilt. `hide_row` / `unhide_row` and
|
|
70
|
+
`hide_column` / `unhide_column` set or restore an axis entry's size to zero;
|
|
71
|
+
indexes are validated against the grid and zero-sized entries are skipped by
|
|
72
|
+
offset lookup. `hide_rows(indices, hidden:)` and `hide_columns(indices, hidden:)`
|
|
73
|
+
apply a validated batch with a single frame request; `row_hidden?` and
|
|
74
|
+
`column_hidden?` report current state. Fill only reports the source and
|
|
75
|
+
destination areas; the application owns cell values and fill semantics. Shift
|
|
76
|
+
extends a range; Cmd/Ctrl-click toggles an additional cell range.
|
|
77
|
+
|
|
78
|
+
`bench/grid.rb` measures a headless 800×600 viewport over a 1,000,000×16,000
|
|
79
|
+
grid at 23 sequential scroll positions, including cell construction, layout,
|
|
80
|
+
prepaint, and paint. `BUDGET=1 ruby bench/grid.rb` asserts a 16.67 ms median
|
|
81
|
+
frame limit. A local run on 2026-09-23 measured 12.964 ms and constructed an
|
|
82
|
+
average of 250 cells per frame. This microbenchmark does not include an
|
|
83
|
+
application's backing store, expensive cell renderers, or a native compositor;
|
|
84
|
+
rerun it on target hardware for deployment decisions.
|
|
85
|
+
|
|
40
86
|
`ScrollView` accepts one child and clips it to the viewport. Its `axis` is
|
|
41
87
|
`:vertical`, `:horizontal`, or `:both`; use `scroll_to` for programmatic
|
|
42
88
|
scrolling and `scroll_state` for offsets and edge checks.
|
data/docs/native.md
CHANGED
|
@@ -49,6 +49,15 @@ and [platform declarations](../sig/platform.rbs) for the exact API.
|
|
|
49
49
|
call `poll(timeout:)` for events and `close` when finished. Overflow events mean
|
|
50
50
|
the application should rescan the watched paths.
|
|
51
51
|
|
|
52
|
+
macOS, Windows, and X11 windows support `move_to_display(display)`, using a
|
|
53
|
+
fresh `Display` returned by `Platform.displays` for the matching backend. It
|
|
54
|
+
moves the window frame to that display's top-left; it raises `Zaniah::Error` if
|
|
55
|
+
the display is no longer available. Headless and TUI windows reject placement.
|
|
56
|
+
Wayland has no arbitrary-position request: `move_to_display` raises, while
|
|
57
|
+
`fullscreen_on(display)` sends a best-effort output preference that the
|
|
58
|
+
compositor is free to ignore. Use `Platform.displays(backend: :linux,
|
|
59
|
+
display_server: :wayland)` to select the output.
|
|
60
|
+
|
|
52
61
|
On Windows, require `zaniah/platform/windows/terminal` to use
|
|
53
62
|
`Zaniah::Platform::Windows::Terminal`, which wraps ConPTY. It supports starting
|
|
54
63
|
a child process, nonblocking reads, writes, resizing, liveness checks, and
|
data/docs/text.md
CHANGED
|
@@ -142,6 +142,28 @@ boundaries. The string-backed buffer is intended for ordinary fields and
|
|
|
142
142
|
documents up to tens of thousands of characters; a piece table is deliberately
|
|
143
143
|
outside the current scope.
|
|
144
144
|
|
|
145
|
+
`UI::RichText` manages a single text buffer with disjoint style spans. Its
|
|
146
|
+
editing API uses UTF-8 byte ranges, and its surface lays out mixed-size runs on
|
|
147
|
+
shared wrapped lines. Editable input is opt-in to preserve the old read-only
|
|
148
|
+
component behavior:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
text = Zaniah::UI::RichText.new([
|
|
152
|
+
{text: "Quarterly ", bold: true},
|
|
153
|
+
{text: "report", italic: true, color: "#2563eb", size: 22}
|
|
154
|
+
], editable: true).w(480)
|
|
155
|
+
|
|
156
|
+
text.apply(0...10, color: "#2563eb")
|
|
157
|
+
text.insert(text.text.bytesize, " — draft")
|
|
158
|
+
text.paragraph_style(0...text.text.bytesize, align: :start, list: :bullet, level: 0)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Supported inline styles are `bold`, `italic`, `size`, `color`, `font`, and
|
|
162
|
+
`link`. Paragraph styles support `align: :start/:center/:end/:justify`,
|
|
163
|
+
`list: :none/:bullet/:ordered`, and nonnegative nesting levels. `on_change`
|
|
164
|
+
receives `(text, rich_text)`; selection changes are reported by `on_select`.
|
|
165
|
+
IME composition uses the existing `TextBuffer` composition path.
|
|
166
|
+
|
|
145
167
|
## Inline and block overlays
|
|
146
168
|
|
|
147
169
|
Attach non-editable UI elements to text without inserting bytes into the source:
|
data/lib/zaniah/gif.rb
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Zaniah
|
|
4
|
+
# Small, dependency-free GIF87a/GIF89a decoder. Frames are returned as RGBA
|
|
5
|
+
# snapshots so callers can choose whether to animate or keep the first frame.
|
|
6
|
+
module GIF
|
|
7
|
+
Frame = Data.define(:pixels, :delay, :disposal)
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def decode(bytes, max_pixels: 16_777_216)
|
|
11
|
+
raise ArgumentError, "not a GIF" unless bytes.start_with?("GIF87a", "GIF89a")
|
|
12
|
+
reader = Reader.new(bytes)
|
|
13
|
+
reader.skip(6)
|
|
14
|
+
width, height, packed, background, = reader.bytes(7).unpack("vvCCC")
|
|
15
|
+
raise ArgumentError, "GIF too large" unless width.positive? && height.positive? && width * height <= max_pixels
|
|
16
|
+
global_palette = palette(reader, packed)
|
|
17
|
+
canvas = Array.new(width * height, [0, 0, 0, 0].freeze)
|
|
18
|
+
frames, gce = [], {delay: 0, disposal: 0, transparent: nil}
|
|
19
|
+
|
|
20
|
+
until reader.eof?
|
|
21
|
+
marker = reader.byte
|
|
22
|
+
case marker
|
|
23
|
+
when 0x3b then break
|
|
24
|
+
when 0x21
|
|
25
|
+
label = reader.byte
|
|
26
|
+
if label == 0xf9
|
|
27
|
+
reader.byte # block size
|
|
28
|
+
flags, delay, transparent = reader.bytes(4).unpack("CvvC")
|
|
29
|
+
reader.byte
|
|
30
|
+
gce = {delay: delay * 0.01, disposal: (flags >> 2) & 7, transparent: flags.anybits?(1) ? transparent : nil}
|
|
31
|
+
else
|
|
32
|
+
skip_subblocks(reader)
|
|
33
|
+
end
|
|
34
|
+
when 0x2c
|
|
35
|
+
frame = read_frame(reader, global_palette, width, height, canvas, gce)
|
|
36
|
+
frames << frame[:frame]
|
|
37
|
+
canvas = frame[:canvas]
|
|
38
|
+
gce = {delay: 0, disposal: 0, transparent: nil}
|
|
39
|
+
else
|
|
40
|
+
raise ArgumentError, "invalid GIF block"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
raise ArgumentError, "GIF has no frames" if frames.empty?
|
|
44
|
+
|
|
45
|
+
[width, height, frames.freeze]
|
|
46
|
+
rescue IndexError => error
|
|
47
|
+
raise ArgumentError, "truncated GIF: #{error.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def palette(reader, packed)
|
|
51
|
+
return [] unless packed.anybits?(0x80)
|
|
52
|
+
size = 2**((packed & 7) + 1)
|
|
53
|
+
reader.bytes(size * 3).bytes.each_slice(3).map { |rgb| [*rgb, 255].freeze }.freeze
|
|
54
|
+
end
|
|
55
|
+
private_class_method :palette
|
|
56
|
+
|
|
57
|
+
def read_frame(reader, global_palette, width, height, previous, gce)
|
|
58
|
+
left, top, frame_width, frame_height, packed = reader.bytes(9).unpack("vvvvC")
|
|
59
|
+
local_palette = palette(reader, packed)
|
|
60
|
+
palette = local_palette.empty? ? global_palette : local_palette
|
|
61
|
+
raise ArgumentError, "GIF frame has no palette" if palette.empty?
|
|
62
|
+
interlaced = packed.anybits?(0x40)
|
|
63
|
+
min_code_size = reader.byte
|
|
64
|
+
compressed = subblocks(reader)
|
|
65
|
+
indices = lzw_decode(compressed, min_code_size, frame_width * frame_height)
|
|
66
|
+
indices = deinterlace(indices, frame_width, frame_height) if interlaced
|
|
67
|
+
canvas = previous.map(&:dup)
|
|
68
|
+
saved = canvas.map(&:dup) if gce[:disposal] == 3
|
|
69
|
+
frame_height.times do |row|
|
|
70
|
+
frame_width.times do |column|
|
|
71
|
+
index = indices[row * frame_width + column]
|
|
72
|
+
next if index == gce[:transparent]
|
|
73
|
+
color = palette[index] || [0, 0, 0, 0].freeze
|
|
74
|
+
x, y = left + column, top + row
|
|
75
|
+
canvas[y * width + x] = color if x.between?(0, width - 1) && y.between?(0, height - 1)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
pixels = canvas.flatten.pack("C*")
|
|
79
|
+
frame = Frame.new(pixels, gce[:delay], gce[:disposal])
|
|
80
|
+
disposed = case gce[:disposal]
|
|
81
|
+
when 2
|
|
82
|
+
canvas.map.with_index { |color, index| image_in_frame?(index, left, top, frame_width, frame_height, width) ? [0, 0, 0, 0].freeze : color }
|
|
83
|
+
when 3 then saved || canvas
|
|
84
|
+
else canvas
|
|
85
|
+
end
|
|
86
|
+
{frame: frame, canvas: disposed}
|
|
87
|
+
end
|
|
88
|
+
private_class_method :read_frame
|
|
89
|
+
|
|
90
|
+
def image_in_frame?(index, left, top, frame_width, frame_height, width)
|
|
91
|
+
x, y = index % width, index / width
|
|
92
|
+
x >= left && x < left + frame_width && y >= top && y < top + frame_height
|
|
93
|
+
end
|
|
94
|
+
private_class_method :image_in_frame?
|
|
95
|
+
|
|
96
|
+
def skip_subblocks(reader)
|
|
97
|
+
loop do
|
|
98
|
+
length = reader.byte
|
|
99
|
+
break if length.zero?
|
|
100
|
+
reader.skip(length)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
private_class_method :skip_subblocks
|
|
104
|
+
|
|
105
|
+
def subblocks(reader)
|
|
106
|
+
result = +"".b
|
|
107
|
+
loop do
|
|
108
|
+
length = reader.byte
|
|
109
|
+
break if length.zero?
|
|
110
|
+
result << reader.bytes(length)
|
|
111
|
+
end
|
|
112
|
+
result
|
|
113
|
+
end
|
|
114
|
+
private_class_method :subblocks
|
|
115
|
+
|
|
116
|
+
def deinterlace(indices, width, height)
|
|
117
|
+
output = Array.new(indices.length)
|
|
118
|
+
offset = 0
|
|
119
|
+
[0, 4, 2, 1].zip([8, 8, 4, 2]).each do |start, step|
|
|
120
|
+
(start...height).step(step) do |row|
|
|
121
|
+
output[row * width, width] = indices[offset, width]
|
|
122
|
+
offset += width
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
output
|
|
126
|
+
end
|
|
127
|
+
private_class_method :deinterlace
|
|
128
|
+
|
|
129
|
+
def lzw_decode(bytes, minimum, expected)
|
|
130
|
+
raise ArgumentError, "invalid GIF LZW size" unless minimum.between?(2, 8)
|
|
131
|
+
clear, stop, code_size = 1 << minimum, (1 << minimum) + 1, minimum + 1
|
|
132
|
+
table = (0...clear).map { |value| [value] } + [nil, nil]
|
|
133
|
+
bit_offset, previous, output = 0, nil, []
|
|
134
|
+
read_code = lambda do
|
|
135
|
+
value = 0
|
|
136
|
+
code_size.times do |bit|
|
|
137
|
+
byte = bytes.getbyte((bit_offset + bit) / 8) || 0
|
|
138
|
+
value |= ((byte >> ((bit_offset + bit) % 8)) & 1) << bit
|
|
139
|
+
end
|
|
140
|
+
bit_offset += code_size
|
|
141
|
+
value
|
|
142
|
+
end
|
|
143
|
+
loop do
|
|
144
|
+
code = read_code.call
|
|
145
|
+
break if code == stop || output.length >= expected
|
|
146
|
+
if code == clear
|
|
147
|
+
table = (0...clear).map { |value| [value] } + [nil, nil]
|
|
148
|
+
code_size = minimum + 1
|
|
149
|
+
previous = nil
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
entry = if code < table.length && table[code]
|
|
153
|
+
table[code]
|
|
154
|
+
elsif previous
|
|
155
|
+
previous + [previous.first]
|
|
156
|
+
else
|
|
157
|
+
raise ArgumentError, "invalid GIF LZW code"
|
|
158
|
+
end
|
|
159
|
+
output.concat(entry)
|
|
160
|
+
if previous
|
|
161
|
+
table << previous + [entry.first]
|
|
162
|
+
code_size += 1 if table.length == (1 << code_size) && code_size < 12
|
|
163
|
+
end
|
|
164
|
+
previous = entry
|
|
165
|
+
end
|
|
166
|
+
raise ArgumentError, "truncated GIF image data" if output.length < expected
|
|
167
|
+
output.first(expected)
|
|
168
|
+
end
|
|
169
|
+
private_class_method :lzw_decode
|
|
170
|
+
|
|
171
|
+
class Reader
|
|
172
|
+
def initialize(bytes)
|
|
173
|
+
@bytes, @offset = bytes, 0
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def eof? = @offset >= @bytes.bytesize
|
|
177
|
+
def byte = bytes(1).getbyte(0)
|
|
178
|
+
def bytes(length)
|
|
179
|
+
value = @bytes.byteslice(@offset, length)
|
|
180
|
+
raise IndexError, "unexpected end of input" unless value && value.bytesize == length
|
|
181
|
+
@offset += length
|
|
182
|
+
value
|
|
183
|
+
end
|
|
184
|
+
def skip(length)
|
|
185
|
+
bytes(length)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/zaniah/image.rb
CHANGED
|
@@ -2,12 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
module Zaniah
|
|
4
4
|
class Image < Element
|
|
5
|
-
def
|
|
5
|
+
def self.from_bytes(bytes, format: :auto) = new(nil, bytes: bytes, format: format)
|
|
6
|
+
|
|
7
|
+
def initialize(path = nil, bytes: nil, format: :auto)
|
|
6
8
|
super()
|
|
7
|
-
|
|
9
|
+
bytes ||= File.binread(path)
|
|
10
|
+
raise ArgumentError, "image bytes must be a String" unless bytes.is_a?(String)
|
|
11
|
+
format = :jpeg if format == :jpg
|
|
12
|
+
format = detect_format(bytes) if format == :auto
|
|
13
|
+
width, height, pixels = case format
|
|
14
|
+
when :png
|
|
15
|
+
PNG.decode(bytes)
|
|
16
|
+
when :jpeg
|
|
17
|
+
JPEG.decode(bytes)
|
|
18
|
+
when :gif
|
|
19
|
+
gif_width, gif_height, frames = GIF.decode(bytes)
|
|
20
|
+
@frames = frames
|
|
21
|
+
[gif_width, gif_height, frames.first.pixels]
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "unsupported image format #{format.inspect}"
|
|
24
|
+
end
|
|
8
25
|
@texture = GPU::Texture.new(width, height, data: pixels)
|
|
9
26
|
end
|
|
10
27
|
|
|
28
|
+
attr_reader :frames
|
|
29
|
+
|
|
11
30
|
def request_layout(_cx)
|
|
12
31
|
@layout_node = Layout::Node.new(style: @style, measure: ->(_width, _height) { [@texture.width, @texture.height] })
|
|
13
32
|
end
|
|
@@ -15,5 +34,14 @@ module Zaniah
|
|
|
15
34
|
def paint(bounds, _state, _prepaint, cx)
|
|
16
35
|
cx.scene.sprite(bounds.x, bounds.y, bounds.width, bounds.height, texture: @texture)
|
|
17
36
|
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def detect_format(bytes)
|
|
41
|
+
return :png if bytes.start_with?(PNG::SIGNATURE)
|
|
42
|
+
return :gif if bytes.start_with?("GIF87a", "GIF89a")
|
|
43
|
+
return :jpeg if bytes.start_with?("\xFF\xD8".b)
|
|
44
|
+
raise ArgumentError, "unsupported image format"
|
|
45
|
+
end
|
|
18
46
|
end
|
|
19
47
|
end
|
data/lib/zaniah/input/keymap.rb
CHANGED
|
@@ -74,6 +74,18 @@ module Zaniah
|
|
|
74
74
|
.bind("pageup", :page_up, context: "in_table")
|
|
75
75
|
.bind("pagedown", :page_down, context: "in_table")
|
|
76
76
|
.bind("#{primary}-a", :select_all, context: "in_table")
|
|
77
|
+
.bind("up", :previous_option, context: "in_grid")
|
|
78
|
+
.bind("down", :next_option, context: "in_grid")
|
|
79
|
+
.bind("left", :previous_column, context: "in_grid")
|
|
80
|
+
.bind("right", :next_column, context: "in_grid")
|
|
81
|
+
.bind("shift-up", :extend_previous, context: "in_grid")
|
|
82
|
+
.bind("shift-down", :extend_next, context: "in_grid")
|
|
83
|
+
.bind("shift-left", :extend_column_previous, context: "in_grid")
|
|
84
|
+
.bind("shift-right", :extend_column_next, context: "in_grid")
|
|
85
|
+
.bind("home", :first, context: "in_grid")
|
|
86
|
+
.bind("end", :last, context: "in_grid")
|
|
87
|
+
.bind("enter", :activate, context: "in_grid")
|
|
88
|
+
.bind("#{primary}-a", :select_all, context: "in_grid")
|
|
77
89
|
.bind("up", :previous_option, context: "in_combobox")
|
|
78
90
|
.bind("down", :next_option, context: "in_combobox")
|
|
79
91
|
.bind("home", :first, context: "in_combobox")
|