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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bf9a2c8a283e0a76e76d5c3e8bd3f10c8a54d6da3fd4736e96d5f9ad5c35a50a
4
- data.tar.gz: 1374f35e06698a1e3ba7dd793a7189339adc8bb57035a1c6032c96264afc4550
3
+ metadata.gz: d5d0895db7cb838e553fc0e7de6c20df016c9c56d3938cfaa94dc99cc47f2ab2
4
+ data.tar.gz: ef786f1e8ee6e87da921f317bd7901d97553e1fbe9e3e6969bb5df588a300754
5
5
  SHA512:
6
- metadata.gz: bace7f7d90e3f029821584b609c22af25f9281cabde5e4a8ce1082aa98a05423e3f994dcc0f3890ee16fc6631e7acd006ec1040e8a56ae62734bb74645989de3
7
- data.tar.gz: d0d64e184d2f2930eeb4264e58b9f6687af8fa466a65de07560a8931f5327122332959e85ad2496c48fbc8e1c6e0f02456d9c3979d62182117092e46d654bcb7
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 selectable runs | text |
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 initialize(path)
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
- width, height, pixels = PNG.decode(File.binread(path))
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
@@ -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")