tuile 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +378 -0
- data/examples/file_commander.rb +196 -0
- data/examples/hello_world.rb +29 -0
- data/lib/tuile/component/has_content.rb +69 -0
- data/lib/tuile/component/info_window.rb +30 -0
- data/lib/tuile/component/label.rb +63 -0
- data/lib/tuile/component/layout.rb +98 -0
- data/lib/tuile/component/list.rb +583 -0
- data/lib/tuile/component/log_window.rb +59 -0
- data/lib/tuile/component/picker_window.rb +97 -0
- data/lib/tuile/component/popup.rb +127 -0
- data/lib/tuile/component/text_field.rb +209 -0
- data/lib/tuile/component/window.rb +215 -0
- data/lib/tuile/component.rb +236 -0
- data/lib/tuile/event_queue.rb +192 -0
- data/lib/tuile/fake_event_queue.rb +31 -0
- data/lib/tuile/fake_screen.rb +58 -0
- data/lib/tuile/keys.rb +63 -0
- data/lib/tuile/mouse_event.rb +49 -0
- data/lib/tuile/point.rb +14 -0
- data/lib/tuile/rect.rb +58 -0
- data/lib/tuile/screen.rb +377 -0
- data/lib/tuile/screen_pane.rb +174 -0
- data/lib/tuile/size.rb +42 -0
- data/lib/tuile/version.rb +6 -0
- data/lib/tuile/vertical_scroll_bar.rb +46 -0
- data/lib/tuile.rb +37 -0
- data/sig/tuile.rbs +1502 -0
- metadata +197 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A mixin interface for a component with one child tops. The host must
|
|
6
|
+
# provide a protected `layout(content)` method which repositions the
|
|
7
|
+
# content component; the mixin manages `@content` itself.
|
|
8
|
+
module HasContent
|
|
9
|
+
# @return [Component, nil] the current content component.
|
|
10
|
+
attr_reader :content
|
|
11
|
+
|
|
12
|
+
# @param key [String] a key.
|
|
13
|
+
# @return [Boolean] true if the key was handled, false if not.
|
|
14
|
+
def handle_key(key)
|
|
15
|
+
content.nil? || !content.active? ? false : content.handle_key(key)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param event [MouseEvent]
|
|
19
|
+
# @return [void]
|
|
20
|
+
def handle_mouse(event)
|
|
21
|
+
content.handle_mouse(event) if !content.nil? && content.rect.contains?(event.point)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [Array<Component>]
|
|
25
|
+
def children = content.nil? ? [] : [content]
|
|
26
|
+
|
|
27
|
+
# Sets the new content of this component. Updates `@content` itself;
|
|
28
|
+
# including classes may still override to add behaviour (e.g. a
|
|
29
|
+
# special-cased Array input) but should call `super` to perform the
|
|
30
|
+
# swap.
|
|
31
|
+
# @param content [Component, nil] the component to set or clear.
|
|
32
|
+
# @return [void]
|
|
33
|
+
def content=(content)
|
|
34
|
+
unless content.nil? || content.is_a?(Component)
|
|
35
|
+
raise TypeError, "expected Component or nil, got #{content.inspect}"
|
|
36
|
+
end
|
|
37
|
+
return if self.content == content
|
|
38
|
+
if !content.nil? && !content.parent.nil?
|
|
39
|
+
raise ArgumentError, "#{content} already has a parent #{content.parent}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
old = self.content
|
|
43
|
+
old&.parent = nil
|
|
44
|
+
@content = content
|
|
45
|
+
unless content.nil?
|
|
46
|
+
content.parent = self
|
|
47
|
+
content.invalidate
|
|
48
|
+
layout(content)
|
|
49
|
+
end
|
|
50
|
+
on_child_removed(old) unless old.nil?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @param rect [Rect]
|
|
54
|
+
# @return [void]
|
|
55
|
+
def rect=(rect)
|
|
56
|
+
super
|
|
57
|
+
layout(content) unless content.nil?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [void]
|
|
61
|
+
def on_focus
|
|
62
|
+
super
|
|
63
|
+
# Let the content component receive focus, so that it can immediately
|
|
64
|
+
# start responding to key presses.
|
|
65
|
+
screen.focused = content if !content.nil? && content.focusable?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A {Window} preconfigured with a {List} of static lines. Useful for
|
|
6
|
+
# showing read-only information.
|
|
7
|
+
#
|
|
8
|
+
# Usable tiled (just add to a {Layout}) or as a popup via {.open}, which
|
|
9
|
+
# wraps it in a {Popup}.
|
|
10
|
+
class InfoWindow < Window
|
|
11
|
+
# @param caption [String]
|
|
12
|
+
# @param lines [Array<String>] initial content; each entry may contain
|
|
13
|
+
# Rainbow formatting.
|
|
14
|
+
def initialize(caption = "", lines = [])
|
|
15
|
+
super(caption)
|
|
16
|
+
list = Component::List.new
|
|
17
|
+
list.lines = lines
|
|
18
|
+
self.content = list
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Opens the info window as a popup.
|
|
22
|
+
# @param caption [String]
|
|
23
|
+
# @param lines [Array<String>] the content, may contain formatting.
|
|
24
|
+
# @return [Popup] the opened popup.
|
|
25
|
+
def self.open(caption, lines)
|
|
26
|
+
Popup.open(content: InfoWindow.new(caption, lines))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A label which shows static text. No word-wrapping; clips long lines.
|
|
6
|
+
class Label < Component
|
|
7
|
+
def initialize
|
|
8
|
+
super
|
|
9
|
+
@lines = []
|
|
10
|
+
@clipped_lines = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @param text [String, nil] draws this text. May contain ANSI formatting.
|
|
14
|
+
# Clipped automatically.
|
|
15
|
+
# @return [void]
|
|
16
|
+
def text=(text)
|
|
17
|
+
@lines = text.to_s.split("\n")
|
|
18
|
+
@content_size = nil
|
|
19
|
+
update_clipped_text
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Size]
|
|
23
|
+
def content_size
|
|
24
|
+
@content_size ||= begin
|
|
25
|
+
width = @lines.map { |line| Unicode::DisplayWidth.of(Rainbow.uncolor(line)) }.max || 0
|
|
26
|
+
Size.new(width, @lines.size)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [void]
|
|
31
|
+
def repaint
|
|
32
|
+
clear_background
|
|
33
|
+
height = rect.height.clamp(0, nil)
|
|
34
|
+
lines_to_print = @clipped_lines.length.clamp(nil, height)
|
|
35
|
+
(0..lines_to_print - 1).each do |index|
|
|
36
|
+
screen.print TTY::Cursor.move_to(rect.left, rect.top + index), @clipped_lines[index]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
protected
|
|
41
|
+
|
|
42
|
+
# @return [void]
|
|
43
|
+
def on_width_changed
|
|
44
|
+
super
|
|
45
|
+
update_clipped_text
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @return [void]
|
|
51
|
+
def update_clipped_text
|
|
52
|
+
len = rect.width.clamp(0, nil)
|
|
53
|
+
clipped = @lines.map do |line|
|
|
54
|
+
Strings::Truncation.truncate(line, length: len)
|
|
55
|
+
end
|
|
56
|
+
return if @clipped_lines == clipped
|
|
57
|
+
|
|
58
|
+
@clipped_lines = clipped
|
|
59
|
+
invalidate
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A layout doesn't paint anything by itself: its job is to position child
|
|
6
|
+
# components.
|
|
7
|
+
#
|
|
8
|
+
# All children must completely cover the contents of a layout: that way,
|
|
9
|
+
# the layout itself doesn't have to draw and no clipping algorithm is
|
|
10
|
+
# necessary.
|
|
11
|
+
class Layout < Component
|
|
12
|
+
def initialize
|
|
13
|
+
super
|
|
14
|
+
@children = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Array<Component>]
|
|
18
|
+
def children = @children.to_a
|
|
19
|
+
|
|
20
|
+
# Adds a child component to this layout.
|
|
21
|
+
# @param child [Component, Array<Component>]
|
|
22
|
+
# @return [void]
|
|
23
|
+
def add(child)
|
|
24
|
+
if child.is_a? Enumerable
|
|
25
|
+
child.each { add(it) }
|
|
26
|
+
else
|
|
27
|
+
raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
|
|
28
|
+
raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
|
|
29
|
+
|
|
30
|
+
@children << child
|
|
31
|
+
child.parent = self
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param child [Component]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def remove(child)
|
|
38
|
+
raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
|
|
39
|
+
raise ArgumentError, "#{child}'s parent is #{child.parent}, not this layout #{self}" if child.parent != self
|
|
40
|
+
|
|
41
|
+
child.parent = nil
|
|
42
|
+
@children.delete(child)
|
|
43
|
+
invalidate if @children.empty?
|
|
44
|
+
on_child_removed(child)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Size]
|
|
48
|
+
def content_size
|
|
49
|
+
return Size::ZERO if @children.empty?
|
|
50
|
+
|
|
51
|
+
right = @children.map { |c| c.rect.left + c.rect.width }.max
|
|
52
|
+
bottom = @children.map { |c| c.rect.top + c.rect.height }.max
|
|
53
|
+
Size.new(right - rect.left, bottom - rect.top)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [void]
|
|
57
|
+
def repaint
|
|
58
|
+
clear_background if @children.empty?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Dispatches the event to the child under the mouse cursor.
|
|
62
|
+
# @param event [MouseEvent]
|
|
63
|
+
# @return [void]
|
|
64
|
+
def handle_mouse(event)
|
|
65
|
+
super
|
|
66
|
+
@children.each do |child|
|
|
67
|
+
child.handle_mouse(event) if child.rect.contains?(event.point)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Called when a character is pressed on the keyboard.
|
|
72
|
+
# @param key [String] a key.
|
|
73
|
+
# @return [Boolean] true if the key was handled, false if not.
|
|
74
|
+
def handle_key(key)
|
|
75
|
+
return true if super
|
|
76
|
+
|
|
77
|
+
sc = @children.find(&:active?)
|
|
78
|
+
return false if sc.nil?
|
|
79
|
+
|
|
80
|
+
sc.handle_key(key)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [void]
|
|
84
|
+
def on_focus
|
|
85
|
+
super
|
|
86
|
+
# Let the content component receive focus, so that it can immediately
|
|
87
|
+
# start responding to key presses.
|
|
88
|
+
first_focusable = @children.find(&:focusable?)
|
|
89
|
+
screen.focused = first_focusable unless first_focusable.nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Absolute layout. Extend this class, register any children, and
|
|
93
|
+
# override {Component#rect=} to reposition the children.
|
|
94
|
+
class Absolute < Layout
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|