tuile 0.7.0 → 0.8.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.
data/lib/tuile/screen.rb CHANGED
@@ -45,6 +45,10 @@ module Tuile
45
45
  # App-level keyboard shortcuts dispatched by {#handle_key} before keys
46
46
  # reach the pane. See {#register_global_shortcut}.
47
47
  @global_shortcuts = {}
48
+ # The back buffer components paint into. {#repaint} flushes its diff to
49
+ # the terminal, so only changed cells are emitted (flicker-free on any
50
+ # terminal). Sized to the current viewport; {#layout} resizes it.
51
+ @buffer = Buffer.new(@size)
48
52
  end
49
53
 
50
54
  # Entry in the global shortcut registry: the block to run, whether it
@@ -56,6 +60,10 @@ module Tuile
56
60
  # @return [ScreenPane] the structural root of the component tree.
57
61
  attr_reader :pane
58
62
 
63
+ # @return [Buffer] the back buffer components paint into
64
+ # ({Buffer#set_line} / {Buffer#fill} / {Buffer#set_char}).
65
+ attr_reader :buffer
66
+
59
67
  # Handler invoked when a {StandardError} escapes an event handler inside
60
68
  # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
61
69
  #
@@ -232,7 +240,7 @@ module Tuile
232
240
  # @api private
233
241
  # @return [void]
234
242
  def refresh_status_bar
235
- top_popup = @pane.popups.last
243
+ top_popup = @pane.modal_popup
236
244
  globals = global_shortcut_hints(popup_open: !top_popup.nil?)
237
245
  @pane.status_bar.text = if top_popup.nil?
238
246
  ["q #{@theme.hint("quit")}", *globals,
@@ -449,31 +457,16 @@ module Tuile
449
457
  @@instance&.close
450
458
  end
451
459
 
452
- # Prints given strings. While {#repaint} is running, writes are
453
- # accumulated into a frame buffer and flushed to the terminal as a
454
- # single `$stdout.write` at the end of the cycle. This stops the
455
- # emulator from rendering half-finished frames (e.g. a layout's
456
- # clear-background pass before its children have re-painted), which
457
- # was visible as a brief flicker when the auto-clear path triggers.
458
- #
459
- # Outside repaint, writes go straight to stdout. We deliberately
460
- # don't raise on a "print outside repaint" — that would be a useful
461
- # guardrail against components painting outside the repaint loop,
462
- # but it'd force terminal-housekeeping writes (`Screen#clear`,
463
- # mouse-tracking start/stop, cursor-show on teardown) to bypass
464
- # this method entirely and write directly to `$stdout`. {FakeScreen}
465
- # overrides `print` to capture every byte into its `@prints` array,
466
- # and tests that exercise `run_event_loop` against a real {Screen}
467
- # would otherwise leak escape sequences to the test runner's stdout.
468
- # Keeping `print` as the single sink preserves that override seam.
460
+ # Writes terminal-housekeeping escapes straight to stdout: {#clear},
461
+ # mouse-tracking start/stop, the color-scheme notify toggles, cursor-show
462
+ # on teardown. Component painting does *not* go through here anymore — it
463
+ # writes into {#buffer}, which {#repaint} diffs and {#emit}s. {FakeScreen}
464
+ # overrides this (and {#emit}) to capture into `@prints` instead of the
465
+ # test runner's stdout.
469
466
  # @param args [String] stuff to print.
470
467
  # @return [void]
471
468
  def print(*args)
472
- if @frame_buffer
473
- args.each { |s| @frame_buffer << s.to_s }
474
- else
475
- Kernel.print(*args)
476
- end
469
+ Kernel.print(*args)
477
470
  end
478
471
 
479
472
  # Repaints the screen; tries to be as effective as possible, by only
@@ -489,64 +482,61 @@ module Tuile
489
482
  # simple and very fast in common cases.
490
483
 
491
484
  did_paint = false
492
- @frame_buffer = +""
493
- begin
494
- until @invalidated.empty?
495
- # Defensive filter: a component can become detached between enqueue
496
- # and drain (popup close, sibling removed mid-event-handling, focus
497
- # repair). Detached components have no place on the screen and must
498
- # never paint, even though Component#invalidate already gates them
499
- # out — this catches the case where attachment changed since.
500
- @invalidated.delete_if { |c| !c.attached? }
501
- break if @invalidated.empty?
502
-
503
- did_paint = true
504
- popups = @pane.popups
505
-
506
- # Partition invalidated components into tiled vs popup-tree. Sorting
507
- # by depth across the whole tree would interleave them: a tiled
508
- # grandchild (depth 3) sorts after a popup's content (depth 2) and
509
- # overdraws it.
510
- popup_tree = Set.new
511
- popups.each { |p| p.on_tree { popup_tree << _1 } }
512
- tiled, popup_invalidated = @invalidated.to_a.partition { !popup_tree.include?(_1) }
513
-
514
- # Within the tiled tree, paint parents before children.
515
- tiled.sort_by!(&:depth)
516
-
517
- repaint = if tiled.empty?
518
- # Only popups need repaint — paint just their invalidated
519
- # components in depth order.
520
- popup_invalidated.sort_by(&:depth)
521
- else
522
- # Tiled components may overdraw popups; repaint each open
523
- # popup's full subtree on top, in stacking order
524
- # (parent-before-child within each popup).
525
- tiled + popups.flat_map { |p| collect_subtree(p) }
526
- end
527
-
528
- @repainting = repaint.to_set
529
- @invalidated.clear
530
-
531
- # Don't call {#clear} before repaint — causes flickering, and only
532
- # needed when @content doesn't cover the entire screen.
533
- repaint.each(&:repaint)
534
-
535
- # Repaint done, mark all components as up-to-date.
536
- @repainting.clear
485
+ until @invalidated.empty?
486
+ # Defensive filter: a component can become detached between enqueue
487
+ # and drain (popup close, sibling removed mid-event-handling, focus
488
+ # repair). Detached components have no place on the screen and must
489
+ # never paint, even though Component#invalidate already gates them
490
+ # out this catches the case where attachment changed since.
491
+ @invalidated.delete_if { |c| !c.attached? }
492
+ break if @invalidated.empty?
493
+
494
+ did_paint = true
495
+ popups = @pane.popups
496
+
497
+ # Build the repaint list in z-order, leaning on the tree itself rather
498
+ # than a depth sort. The pane's pre-order traversal already orders the
499
+ # tiled layer (content subtree + status bar) parent-before-child; the
500
+ # popups are the top layer and must paint last. The status bar is a
501
+ # *late* pane child yet sits under the popups, so a single pane.on_tree
502
+ # walk won't do — we collect the tiled layer first, then append popups.
503
+ popup_members = Set.new
504
+ popups.each { |p| p.on_tree { popup_members << _1 } }
505
+
506
+ # Tiled layer: invalidated non-popup components, in tree order.
507
+ repaint = []
508
+ tiled_invalidated = false
509
+ @pane.on_tree do |c|
510
+ next if popup_members.include?(c)
511
+ next unless @invalidated.include?(c)
512
+
513
+ repaint << c
514
+ tiled_invalidated = true
537
515
  end
538
- position_cursor if did_paint
539
- unless @frame_buffer.empty?
540
- $stdout.write(@frame_buffer)
541
- $stdout.flush
516
+
517
+ # Popups on top: the whole stack when a tiled repaint may have clobbered
518
+ # cells they share in the buffer, else just the invalidated popup
519
+ # components. Overdraw into the buffer is free (only net-visible cell
520
+ # changes reach the terminal), so reasserting the stack is cheap.
521
+ popups.each do |p|
522
+ p.on_tree { |c| repaint << c if tiled_invalidated || @invalidated.include?(c) }
542
523
  end
543
- ensure
544
- # Always release the frame buffer, even on exception, so any
545
- # subsequent {#print} call (e.g. teardown emits during crash unwind)
546
- # reaches stdout instead of being swallowed by a stranded buffer.
547
- # The partial frame we hold here is incoherent discard it.
548
- @frame_buffer = nil
524
+
525
+ @repainting = repaint.to_set
526
+ @invalidated.clear
527
+
528
+ # Components write into @buffer; overdraw is free and correct here
529
+ # because the buffer only diffs net-visible changes to the terminal.
530
+ repaint.each(&:repaint)
531
+
532
+ # Repaint done, mark all components as up-to-date.
533
+ @repainting.clear
549
534
  end
535
+ return unless did_paint
536
+
537
+ # Flush only the changed cells, then reposition the cursor — all inside
538
+ # one synchronized-output batch so the terminal composites it atomically.
539
+ emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
550
540
  end
551
541
 
552
542
  # Returns the absolute screen coordinates where the hardware cursor should
@@ -588,7 +578,7 @@ module Tuile
588
578
  # @return [Boolean] true if focus moved.
589
579
  def cycle_focus(forward:)
590
580
  check_locked
591
- scope = @pane.popups.last || @pane.content
581
+ scope = @pane.modal_popup || @pane.content
592
582
  return false if scope.nil?
593
583
 
594
584
  stops = []
@@ -607,25 +597,22 @@ module Tuile
607
597
  true
608
598
  end
609
599
 
610
- # Collects a component and all its descendants in tree order
611
- # (parent before children).
612
- # @param component [Component]
613
- # @return [Array<Component>]
614
- def collect_subtree(component)
615
- result = []
616
- component.on_tree { result << _1 }
617
- result
600
+ # The escape sequence positioning the hardware cursor for the current focus
601
+ # state: hidden when nothing owns it, else moved to the focused component's
602
+ # {Component#cursor_position} and shown. Appended to each frame's flush.
603
+ # @return [String]
604
+ def cursor_sequence
605
+ pos = cursor_position
606
+ pos.nil? ? TTY::Cursor.hide : "#{TTY::Cursor.move_to(pos.x, pos.y)}#{TTY::Cursor.show}"
618
607
  end
619
608
 
620
- # Hides or moves the hardware cursor based on the current focus state.
609
+ # Writes an assembled frame (escape string) to the terminal. The single
610
+ # sink for repaint output; {FakeScreen} overrides it to capture instead.
611
+ # @param str [String]
621
612
  # @return [void]
622
- def position_cursor
623
- pos = cursor_position
624
- if pos.nil?
625
- print TTY::Cursor.hide
626
- else
627
- print TTY::Cursor.move_to(pos.x, pos.y), TTY::Cursor.show
628
- end
613
+ def emit(str)
614
+ $stdout.write(str)
615
+ $stdout.flush
629
616
  end
630
617
 
631
618
  # Recalculates positions of all windows, and repaints the scene.
@@ -634,6 +621,7 @@ module Tuile
634
621
  # @return [void]
635
622
  def layout
636
623
  check_locked
624
+ @buffer.resize(size) unless @buffer.size == size
637
625
  needs_full_repaint
638
626
  @pane.rect = Rect.new(0, 0, size.width, size.height)
639
627
  repaint
@@ -649,10 +637,12 @@ module Tuile
649
637
  # doesn't trap them.
650
638
  # 2. App-level shortcuts from {#register_global_shortcut}. An entry
651
639
  # registered with `over_popups: true` always fires; one with the
652
- # default `over_popups: false` fires only when no popup is open
653
- # (otherwise the popup receives the key normally).
654
- # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
655
- # tiled content.
640
+ # default `over_popups: false` fires only when no modal popup is open
641
+ # (otherwise the modal popup receives the key normally). A non-modal
642
+ # overlay doesn't suppress global shortcuts.
643
+ # 3. {ScreenPane#handle_key}, which captures a matching {#key_shortcut}
644
+ # in the active scope, then delivers the key to {#focused} and bubbles
645
+ # it up the focus chain.
656
646
  # @param key [String]
657
647
  # @return [Boolean] true if the key was handled by some window.
658
648
  def handle_key(key)
@@ -665,7 +655,7 @@ module Tuile
665
655
  true
666
656
  else
667
657
  shortcut = @global_shortcuts[key]
668
- if !shortcut.nil? && (shortcut.over_popups || @pane.popups.empty?)
658
+ if !shortcut.nil? && (shortcut.over_popups || @pane.modal_popup.nil?)
669
659
  shortcut.block.call
670
660
  true
671
661
  else
@@ -28,8 +28,9 @@ module Tuile
28
28
 
29
29
  # @return [Component, nil] the tiled content component.
30
30
  attr_reader :content
31
- # @return [Array<Component>] modal popups in stacking order; last is
32
- # topmost. The array must not be mutated by callers.
31
+ # @return [Array<Component>] overlay popups in stacking order; last is
32
+ # topmost. Holds both modal popups and non-modal overlays
33
+ # ({Component::Popup#modal?}). The array must not be mutated by callers.
33
34
  attr_reader :popups
34
35
  # @return [Component::Label] the bottom status bar.
35
36
  attr_reader :status_bar
@@ -57,7 +58,11 @@ module Tuile
57
58
  layout
58
59
  end
59
60
 
60
- # Adds a popup, centers it, focuses it, and invalidates it for repaint.
61
+ # Adds a popup and invalidates it for repaint. A modal popup is centered
62
+ # and grabs focus; a non-modal overlay ({Component::Popup#modal?} false) is
63
+ # left wherever the caller positions it and does *not* take focus, so the
64
+ # component that was focused keeps the cursor and keeps receiving keys —
65
+ # the overlay floats above the content, driven from app code.
61
66
  # @param window [Component::Popup]
62
67
  # @return [void]
63
68
  def add_popup(window)
@@ -67,8 +72,10 @@ module Tuile
67
72
  @popup_prior_focus[window] = screen.focused
68
73
  @popups << window
69
74
  window.parent = self
70
- window.center
71
- screen.focused = window
75
+ if window.modal?
76
+ window.center
77
+ screen.focused = window
78
+ end
72
79
  screen.invalidate(window)
73
80
  end
74
81
 
@@ -100,6 +107,13 @@ module Tuile
100
107
  # @return [Boolean] true if this pane currently hosts the popup.
101
108
  def has_popup?(window) = @popups.include?(window) # rubocop:disable Naming/PredicatePrefix
102
109
 
110
+ # @return [Component::Popup, nil] the topmost *modal* popup, or nil when
111
+ # only non-modal overlays (or no popups) are open. This is the "modal
112
+ # owner": the popup that scopes key dispatch, blocks mouse clicks, owns
113
+ # the status bar, and confines Tab cycling. Non-modal overlays are
114
+ # excluded — they float above the content without capturing input.
115
+ def modal_popup = @popups.reverse_each.find(&:modal?)
116
+
103
117
  # Re-lays out children whenever the pane's own rect changes.
104
118
  # @param new_rect [Rect]
105
119
  # @return [void]
@@ -109,13 +123,14 @@ module Tuile
109
123
  end
110
124
 
111
125
  # Lays out content (full pane minus the bottom row) and the status bar
112
- # (bottom row). Popups self-position via {Component::Popup#center}.
126
+ # (bottom row). Modal popups self-recenter via {Component::Popup#center};
127
+ # non-modal overlays keep the position their owner assigned.
113
128
  # @return [void]
114
129
  def layout
115
130
  return if rect.empty?
116
131
 
117
132
  @content.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height - 1, 0].max) unless @content.nil?
118
- @popups.each(&:center)
133
+ @popups.each { |p| p.center if p.modal? }
119
134
  @status_bar.rect = Rect.new(rect.left, rect.top + rect.height - 1, rect.width, 1)
120
135
  end
121
136
 
@@ -123,32 +138,55 @@ module Tuile
123
138
  # @return [void]
124
139
  def repaint; end
125
140
 
126
- # Topmost popup is modal: it eats keys. Falls through to content only
127
- # when no popup is open.
141
+ # Dispatches a key in two phases, both scoped to the topmost *modal* popup
142
+ # (when one is open) or else the tiled {#content}. Non-modal overlays are
143
+ # never the scope: focus stays in the content beneath them, and the overlay
144
+ # is driven by app code (which forwards keys to it explicitly), so it
145
+ # doesn't appear in this path at all.
146
+ #
147
+ # 1. *Capture* — a {Component#key_shortcut} match anywhere in the scope
148
+ # focuses that component and consumes the key. Suppressed while a
149
+ # cursor-owner ({Screen#cursor_position}) is mid-edit, so typing into a
150
+ # {Component::TextField} isn't hijacked by a sibling's shortcut.
151
+ # 2. *Delivery* — the key is handed to {Screen#focused} and bubbles up its
152
+ # ancestor chain to the scope root; the first component to return true
153
+ # wins. Focus that is nil or sits outside the scope receives nothing,
154
+ # which is what keeps an open modal popup modal.
155
+ # @param key [String]
156
+ # @return [Boolean] true if the key was handled.
128
157
  def handle_key(key)
129
- topmost = @popups.last
130
- return topmost.handle_key(key) unless topmost.nil?
131
- return @content.handle_key(key) unless @content.nil?
158
+ scope = modal_popup || @content
159
+ return false if scope.nil?
132
160
 
133
- false
161
+ if screen.cursor_position.nil?
162
+ target = scope.find_shortcut_component(key)
163
+ unless target.nil?
164
+ screen.focused = target
165
+ return true
166
+ end
167
+ end
168
+
169
+ bubble_key(key, scope)
134
170
  end
135
171
 
136
172
  # Mouse events check popups in reverse stacking order (topmost first), and
137
- # fall through to content only when no popup is hit *and* there are no
138
- # popups open. This preserves modal click-blocking: an open popup eats
139
- # clicks even outside its rect.
173
+ # fall through to content only when no popup is hit *and* no modal popup is
174
+ # open. This preserves modal click-blocking an open modal eats clicks
175
+ # even outside its rect — while a non-modal overlay blocks nothing: clicks
176
+ # inside it route to it (e.g. click-to-select), clicks elsewhere reach the
177
+ # content beneath.
140
178
  # @param event [MouseEvent]
141
179
  # @return [void]
142
180
  def handle_mouse(event)
143
181
  clicked = @popups.reverse_each.find { _1.rect.contains?(event.point) }
144
- clicked = @content if clicked.nil? && @popups.empty?
182
+ clicked = @content if clicked.nil? && modal_popup.nil?
145
183
  clicked&.handle_mouse(event)
146
184
  end
147
185
 
148
186
  # Focus repair when a child detaches. Default {Component#on_child_removed}
149
187
  # would refocus to `self` (the pane), which isn't a useful focus target.
150
188
  # Instead, route focus to the first interactable widget in the now-topmost
151
- # popup; falling back to the focus snapshotted when this popup was opened
189
+ # modal popup; falling back to the focus snapshotted when this popup was opened
152
190
  # (if still attached and still focusable); then to the first interactable
153
191
  # widget in {#content}; then to {#content} itself; then nil.
154
192
  #
@@ -167,7 +205,7 @@ module Tuile
167
205
  cursor = f
168
206
  while cursor
169
207
  if cursor == child
170
- fallback = first_tab_stop_or_root(@popups.last)
208
+ fallback = first_tab_stop_or_root(modal_popup)
171
209
  if fallback.nil? && @removing_popup_prior&.attached? && @removing_popup_prior.focusable?
172
210
  fallback = @removing_popup_prior
173
211
  end
@@ -181,6 +219,29 @@ module Tuile
181
219
 
182
220
  private
183
221
 
222
+ # Delivers `key` to {Screen#focused} and bubbles it up the ancestor chain,
223
+ # stopping at (and including) `scope`. Delivers to no one — returning false
224
+ # — when focus is nil or sits outside `scope`; the latter is what makes an
225
+ # open popup modal, since focus is always inside it and content beneath
226
+ # never receives keys.
227
+ # @param key [String]
228
+ # @param scope [Component] the modal scope root (topmost popup or content).
229
+ # @return [Boolean] true if some component on the chain handled the key.
230
+ def bubble_key(key, scope)
231
+ chain = []
232
+ cursor = screen.focused
233
+ until cursor.nil?
234
+ chain << cursor
235
+ break if cursor.equal?(scope)
236
+
237
+ cursor = cursor.parent
238
+ end
239
+ return false unless chain.last.equal?(scope)
240
+
241
+ chain.each { |c| return true if c.handle_key(key) }
242
+ false
243
+ end
244
+
184
245
  # First {Component#tab_stop?} in `root`'s subtree (pre-order), falling
185
246
  # back to `root` itself when the subtree has no tab stops. Returns `nil`
186
247
  # if `root` is `nil`.
@@ -105,6 +105,45 @@ module Tuile
105
105
  # @param overrides [Hash{Symbol => Object}]
106
106
  # @return [Style]
107
107
  def merge(**overrides) = self.class.new(**to_h.merge(overrides))
108
+
109
+ # Minimal SGR escape that transitions a terminal already showing `self`
110
+ # into `other`: only the attributes that differ are emitted. Returns
111
+ # `""` when the styles are identical (nothing to do), and {Ansi::RESET}
112
+ # (`\e[0m`, one code) when `other` is the default style — shorter than
113
+ # turning each attribute off individually.
114
+ #
115
+ # Shared by {StyledString#to_ansi} (diffing span-to-span from the default
116
+ # style) and {Buffer}'s flush (diffing cell-to-cell against the style the
117
+ # terminal currently holds), so both emit identical minimal sequences.
118
+ # @param other [Style] the style to transition to.
119
+ # @return [String]
120
+ def sgr_to(other)
121
+ return "" if self == other
122
+ return Ansi::RESET if other.default?
123
+
124
+ codes = []
125
+ codes << (other.bold ? 1 : 22) if bold != other.bold
126
+ codes << (other.italic ? 3 : 23) if italic != other.italic
127
+ codes << (other.underline ? 4 : 24) if underline != other.underline
128
+ codes << (other.strikethrough ? 9 : 29) if strikethrough != other.strikethrough
129
+ codes.concat(color_codes(other.fg, target: :fg)) if fg != other.fg
130
+ codes.concat(color_codes(other.bg, target: :bg)) if bg != other.bg
131
+ return "" if codes.empty?
132
+
133
+ "\e[#{codes.join(";")}m"
134
+ end
135
+
136
+ private
137
+
138
+ # @param color [Color, nil]
139
+ # @param target [Symbol] either `:fg` or `:bg`.
140
+ # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default"
141
+ # reset when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
142
+ def color_codes(color, target:)
143
+ return [target == :fg ? 39 : 49] if color.nil?
144
+
145
+ color.sgr_codes(target)
146
+ end
108
147
  end
109
148
 
110
149
  # A maximal run of text sharing a single {Style}. `text` is plain — it
@@ -586,7 +625,7 @@ module Tuile
586
625
  out = +""
587
626
  current = Style::DEFAULT
588
627
  @spans.each do |span|
589
- out << sgr_diff(current, span.style)
628
+ out << current.sgr_to(span.style)
590
629
  out << span.text
591
630
  current = span.style
592
631
  end
@@ -611,35 +650,6 @@ module Tuile
611
650
  result
612
651
  end
613
652
 
614
- # @param from [Style]
615
- # @param to [Style]
616
- # @return [String]
617
- def sgr_diff(from, to)
618
- return "" if from == to
619
- return Ansi::RESET if to.default?
620
-
621
- codes = []
622
- codes << (to.bold ? 1 : 22) if from.bold != to.bold
623
- codes << (to.italic ? 3 : 23) if from.italic != to.italic
624
- codes << (to.underline ? 4 : 24) if from.underline != to.underline
625
- codes << (to.strikethrough ? 9 : 29) if from.strikethrough != to.strikethrough
626
- codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
627
- codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
628
- return "" if codes.empty?
629
-
630
- "\e[#{codes.join(";")}m"
631
- end
632
-
633
- # @param color [Color, nil]
634
- # @param target [Symbol] `:fg` or `:bg`.
635
- # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
636
- # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
637
- def color_codes(color, target:)
638
- return [target == :fg ? 39 : 49] if color.nil?
639
-
640
- color.sgr_codes(target)
641
- end
642
-
643
653
  # @param start_or_range [Integer, Range]
644
654
  # @param len [Integer, nil]
645
655
  # @param total [Integer] receiver's full display width.
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.7.0"
5
+ VERSION = "0.8.0"
6
6
  end