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.
@@ -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