potty 0.0.1

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +31 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +270 -0
  5. data/bin/potty_demo +128 -0
  6. data/examples/test_view.rb +87 -0
  7. data/lib/potty/animator.rb +127 -0
  8. data/lib/potty/application.rb +136 -0
  9. data/lib/potty/border.rb +51 -0
  10. data/lib/potty/events.rb +46 -0
  11. data/lib/potty/keys.rb +71 -0
  12. data/lib/potty/layout.rb +47 -0
  13. data/lib/potty/sprite.rb +49 -0
  14. data/lib/potty/sprites/sample.rb +36 -0
  15. data/lib/potty/style.rb +14 -0
  16. data/lib/potty/surface.rb +46 -0
  17. data/lib/potty/surfaces/curses_surface.rb +114 -0
  18. data/lib/potty/surfaces/inline_surface.rb +148 -0
  19. data/lib/potty/theme.rb +82 -0
  20. data/lib/potty/version.rb +5 -0
  21. data/lib/potty/view.rb +132 -0
  22. data/lib/potty/widgets/base.rb +114 -0
  23. data/lib/potty/widgets/button.rb +52 -0
  24. data/lib/potty/widgets/checkbox_group.rb +101 -0
  25. data/lib/potty/widgets/colored_fields_item.rb +56 -0
  26. data/lib/potty/widgets/container.rb +113 -0
  27. data/lib/potty/widgets/countdown.rb +81 -0
  28. data/lib/potty/widgets/flash_message.rb +69 -0
  29. data/lib/potty/widgets/label.rb +37 -0
  30. data/lib/potty/widgets/list.rb +192 -0
  31. data/lib/potty/widgets/list_item.rb +120 -0
  32. data/lib/potty/widgets/panel.rb +49 -0
  33. data/lib/potty/widgets/progress_bar.rb +55 -0
  34. data/lib/potty/widgets/radio_group.rb +121 -0
  35. data/lib/potty/widgets/spinner.rb +84 -0
  36. data/lib/potty/widgets/status_bar.rb +56 -0
  37. data/lib/potty/widgets/text_input.rb +138 -0
  38. data/lib/potty/widgets/toggle.rb +65 -0
  39. data/lib/potty/window_manager.rb +55 -0
  40. data/lib/potty.rb +35 -0
  41. metadata +112 -0
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../layout'
5
+
6
+ module Potty
7
+ module Widgets
8
+ # A widget that holds child widgets and lays them out within its own
9
+ # rect. Render, tick, and focus traversal recurse into children, so a
10
+ # View's flat `@widgets` array can now contain arbitrarily nested
11
+ # structure (a VBox of HBoxes of fields, etc.) while the View itself
12
+ # stays unchanged.
13
+ #
14
+ # Subclasses implement `layout_children` (assign each child a rect) and
15
+ # `preferred_height`.
16
+ class Container < Base
17
+ attr_reader :children
18
+
19
+ def initialize(app, spacing: 0)
20
+ super(app)
21
+ @children = []
22
+ @spacing = spacing
23
+ end
24
+
25
+ def add(*widgets)
26
+ widgets.flatten.each do |w|
27
+ w.parent = self
28
+ @children << w
29
+ end
30
+ self
31
+ end
32
+ alias << add
33
+
34
+ # Focusable leaf descendants, in visual order (depth-first).
35
+ def focusable_widgets
36
+ @children.flat_map do |child|
37
+ if child.is_a?(Container)
38
+ child.focusable_widgets
39
+ elsif child.can_focus?
40
+ [child]
41
+ else
42
+ []
43
+ end
44
+ end
45
+ end
46
+
47
+ def on_layout
48
+ layout_children
49
+ end
50
+
51
+ # Override in subclasses.
52
+ def layout_children
53
+ # no-op
54
+ end
55
+
56
+ def render(window)
57
+ return unless @visible && @rect
58
+
59
+ @children.each { |child| child.render(window) if child.visible? }
60
+ end
61
+
62
+ def tick(now)
63
+ @children.each { |child| child.tick(now) }
64
+ end
65
+ end
66
+
67
+ # Vertical stack — children top to bottom, each at its preferred height.
68
+ class VBox < Container
69
+ def preferred_height(width)
70
+ return 0 if @children.empty?
71
+
72
+ @children.sum { |c| c.preferred_height(width) } + @spacing * (@children.size - 1)
73
+ end
74
+
75
+ def layout_children
76
+ rects = Layout.stack(@rect.dup, @children, spacing: @spacing)
77
+ @children.zip(rects).each { |child, rect| child.layout(rect) }
78
+ end
79
+ end
80
+
81
+ # Horizontal row — children share the width in equal columns (the last
82
+ # absorbs rounding), each spanning the full height.
83
+ class HBox < Container
84
+ def preferred_height(width)
85
+ return 0 if @children.empty?
86
+
87
+ @children.map { |c| c.preferred_height(child_width(width)) }.max
88
+ end
89
+
90
+ def layout_children
91
+ n = @children.size
92
+ return if n.zero?
93
+
94
+ base = child_width(@rect.width)
95
+ x = @rect.x
96
+ @children.each_with_index do |child, i|
97
+ w = i == n - 1 ? (@rect.x + @rect.width - x) : base
98
+ child.layout(Layout::Rect.new(x, @rect.y, w, @rect.height))
99
+ x += w + @spacing
100
+ end
101
+ end
102
+
103
+ private
104
+
105
+ def child_width(width)
106
+ n = @children.size
107
+ return width if n.zero?
108
+
109
+ [(width - @spacing * (n - 1)) / n, 1].max
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Potty
6
+ module Widgets
7
+ # Passive display that counts down from N seconds and fires on_expire
8
+ # once when it reaches zero. Time-driven: it advances off the `now`
9
+ # passed to tick(now), so drive it via the Application tick loop
10
+ # (set Application#tick_interval).
11
+ #
12
+ # The clock starts on the first tick (not at construction), so a
13
+ # Countdown built well before the loop spins up still gets its full N.
14
+ class Countdown < Base
15
+ attr_accessor :on_expire
16
+
17
+ def initialize(app, seconds:, on_expire: nil, format: nil)
18
+ super(app)
19
+ @seconds = seconds.to_f
20
+ @on_expire = on_expire
21
+ @format = format || ->(remaining) { "Auto-launching in #{remaining}s\u2026" }
22
+ @started_at = nil
23
+ @last_now = nil
24
+ @running = true
25
+ @expired = false
26
+ end
27
+
28
+ # (Re)start the countdown from the top.
29
+ def start
30
+ @started_at = nil
31
+ @last_now = nil
32
+ @running = true
33
+ @expired = false
34
+ self
35
+ end
36
+
37
+ def stop
38
+ @running = false
39
+ self
40
+ end
41
+
42
+ def expired?
43
+ @expired
44
+ end
45
+
46
+ # Whole seconds left (ceil), clamped at 0.
47
+ def remaining
48
+ return @seconds.ceil if @started_at.nil? || @last_now.nil?
49
+
50
+ [@seconds - (@last_now - @started_at), 0].max.ceil
51
+ end
52
+
53
+ def preferred_height(_width)
54
+ 1
55
+ end
56
+
57
+ def tick(now)
58
+ @last_now = now
59
+ return unless @running
60
+
61
+ @started_at ||= now
62
+ return if @expired
63
+
64
+ return unless now - @started_at >= @seconds
65
+
66
+ @expired = true
67
+ @running = false
68
+ @on_expire&.call(self)
69
+ emit(:expire, self)
70
+ end
71
+
72
+ def render(window)
73
+ return unless @visible && @rect
74
+
75
+ text = @format.call(remaining).to_s[0, @rect.width]
76
+ window.setpos(@rect.y, @rect.x)
77
+ window.attron(theme[:warning]) { window.addstr(text) }
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Potty
6
+ module Widgets
7
+ # Temporary notification message
8
+ class FlashMessage < Base
9
+ attr_reader :message, :type
10
+
11
+ def initialize(app)
12
+ super
13
+ @message = nil
14
+ @type = :info
15
+ @timeout = nil
16
+ end
17
+
18
+ def show(message, type: :info, timeout: 5)
19
+ @message = message
20
+ @type = type
21
+ @timeout = Time.now + timeout if timeout
22
+ end
23
+
24
+ def clear
25
+ @message = nil
26
+ @timeout = nil
27
+ end
28
+
29
+ def preferred_height(width)
30
+ 1
31
+ end
32
+
33
+ def render(window)
34
+ return unless @rect
35
+
36
+ # Auto-clear if timed out
37
+ if @message && @timeout && Time.now > @timeout
38
+ clear
39
+ end
40
+
41
+ # Clear the line first
42
+ window.setpos(@rect.y, @rect.x)
43
+ window.addstr(" " * @rect.width)
44
+
45
+ # Show message if present
46
+ if @message
47
+ attr = case @type
48
+ when :success then theme[:success]
49
+ when :error then theme[:error]
50
+ when :warning then theme[:warning]
51
+ else theme[:info]
52
+ end
53
+
54
+ window.setpos(@rect.y, @rect.x)
55
+ window.attron(attr) do
56
+ prefix = case @type
57
+ when :success then "\u2713 "
58
+ when :error then "\u2717 "
59
+ when :warning then "\u26A0 "
60
+ else "\u2139 "
61
+ end
62
+ text = "#{prefix}#{@message}"[0, @rect.width]
63
+ window.addstr(text)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Potty
6
+ module Widgets
7
+ # Static, non-focusable text — form field labels, headings, captions.
8
+ # Single line, truncated to the rect width. Color is a theme name;
9
+ # `bold:` ORs in A_BOLD via the theme.
10
+ class Label < Base
11
+ attr_accessor :text, :color, :bold
12
+
13
+ def initialize(app, text: '', color: :normal, bold: false)
14
+ super(app)
15
+ @text = text
16
+ @color = color
17
+ @bold = bold
18
+ end
19
+
20
+ def can_focus?
21
+ false
22
+ end
23
+
24
+ def preferred_height(_width)
25
+ 1
26
+ end
27
+
28
+ def render(window)
29
+ return unless @visible && @rect
30
+
31
+ attr = @bold ? theme.attr(@color, bold: true) : theme[@color]
32
+ window.setpos(@rect.y, @rect.x)
33
+ window.attron(attr) { window.addstr(@text.to_s[0, @rect.width] || '') }
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'list_item'
5
+ require_relative '../keys'
6
+ require_relative '../border'
7
+
8
+ module Potty
9
+ module Widgets
10
+ # Scrollable list widget with heterogeneous items.
11
+ # Emits :select(item) on cursor move and :activate(item) on Enter.
12
+ class List < Base
13
+ attr_reader :items
14
+ attr_accessor :on_select, :on_activate
15
+
16
+ def initialize(app)
17
+ super
18
+ @items = []
19
+ @selected_index = 0
20
+ @scroll_offset = 0
21
+ @on_select = nil
22
+ @on_activate = nil
23
+ end
24
+
25
+ # Replacing the items resets the cursor to the first *selectable*
26
+ # item (skipping leading separators/disabled rows) so the initial
27
+ # highlight never lands on something you can't select.
28
+ def items=(list)
29
+ @items = list || []
30
+ @selected_index = first_selectable_index
31
+ @scroll_offset = 0
32
+ end
33
+
34
+ # The currently highlighted item (nil if the list is empty).
35
+ def selected_item
36
+ @items[@selected_index]
37
+ end
38
+
39
+ def can_focus?
40
+ true
41
+ end
42
+
43
+ def preferred_height(width)
44
+ [@items.size + 2, 10].max # Items + borders, minimum 10
45
+ end
46
+
47
+ def render(window)
48
+ return unless @rect
49
+
50
+ visible_height = @rect.height - 2 # Account for borders
51
+
52
+ # Draw border
53
+ draw_border(window)
54
+
55
+ # Show empty message if no items
56
+ if @items.empty?
57
+ window.setpos(@rect.y + 1, @rect.x + 2)
58
+ window.attron(theme[:dim]) do
59
+ window.addstr("No items")
60
+ end
61
+ return
62
+ end
63
+
64
+ # Draw visible items
65
+ visible_items = @items[@scroll_offset, visible_height] || []
66
+ visible_items.each_with_index do |item, display_idx|
67
+ real_idx = @scroll_offset + display_idx
68
+ y = @rect.y + 1 + display_idx
69
+ x = @rect.x + 1
70
+
71
+ render_item(window, item, real_idx, y, x)
72
+ end
73
+ end
74
+
75
+ def handle_key(ch)
76
+ return false if @items.empty?
77
+
78
+ case ch
79
+ when Keys::UP
80
+ move_selection(-1)
81
+ true
82
+ when Keys::DOWN
83
+ move_selection(1)
84
+ true
85
+ when *Keys::ENTERS
86
+ activate_current
87
+ true
88
+ else
89
+ # Delegate to current item (e.g., InputItem handles typing)
90
+ current_item&.handle_key(ch)
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def draw_border(window)
97
+ Border.draw(window, @rect, attr: theme[:normal])
98
+ end
99
+
100
+ def render_item(window, item, index, y, x)
101
+ is_selected = (index == @selected_index)
102
+ is_disabled = item.disabled?
103
+ max_width = @rect.width - 3
104
+
105
+ window.setpos(y, x)
106
+
107
+ # Selection prefix
108
+ prefix_attr = is_selected && @focused ? theme.attr(:selected, bold: true) : theme[:normal]
109
+ window.attron(prefix_attr) do
110
+ prefix = is_selected ? "\u2192 " : " "
111
+ window.addstr(prefix)
112
+ end
113
+
114
+ # Try custom rendering first
115
+ if item.render_custom(window, theme, max_width - 2)
116
+ return # Item handled its own rendering
117
+ end
118
+
119
+ # Default single-color rendering
120
+ attr = if is_selected && @focused
121
+ theme.attr(:selected, bold: true)
122
+ elsif is_disabled
123
+ theme[:dim]
124
+ elsif item.color
125
+ theme[item.color]
126
+ else
127
+ theme[:normal]
128
+ end
129
+
130
+ window.attron(attr) do
131
+ text = item.display_text
132
+ window.addstr(text[0, max_width - 2])
133
+ end
134
+ end
135
+
136
+ def move_selection(delta)
137
+ return if @items.empty?
138
+
139
+ # Find next enabled item
140
+ attempts = 0
141
+ new_index = @selected_index
142
+
143
+ loop do
144
+ new_index = (new_index + delta) % @items.size
145
+ break if !@items[new_index].disabled? || attempts >= @items.size
146
+ attempts += 1
147
+ end
148
+
149
+ @selected_index = new_index
150
+ adjust_scroll
151
+ @on_select&.call(@items[@selected_index])
152
+ emit(:select, @items[@selected_index])
153
+ end
154
+
155
+ def adjust_scroll
156
+ return unless @rect # keys can arrive before the first layout
157
+
158
+ visible_height = @rect.height - 2
159
+
160
+ # Scroll down if needed
161
+ if @selected_index >= @scroll_offset + visible_height
162
+ @scroll_offset = @selected_index - visible_height + 1
163
+ end
164
+
165
+ # Scroll up if needed
166
+ if @selected_index < @scroll_offset
167
+ @scroll_offset = @selected_index
168
+ end
169
+ end
170
+
171
+ def activate_current
172
+ return if @items.empty?
173
+
174
+ item = @items[@selected_index]
175
+ return if item.disabled?
176
+
177
+ @on_activate&.call(item)
178
+ emit(:activate, item)
179
+ item.activate
180
+ end
181
+
182
+ def current_item
183
+ @items[@selected_index] if @selected_index < @items.size
184
+ end
185
+
186
+ # First non-disabled index, or 0 if there is no selectable item.
187
+ def first_selectable_index
188
+ @items.index { |item| !item.disabled? } || 0
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'curses'
4
+ require_relative '../keys'
5
+
6
+ module Potty
7
+ module Widgets
8
+ # Base list item
9
+ class ListItem
10
+ attr_reader :text, :value
11
+ attr_accessor :color
12
+
13
+ def initialize(text, value: nil, color: nil)
14
+ @text = text
15
+ @value = value
16
+ @color = color
17
+ end
18
+
19
+ def display_text
20
+ @text
21
+ end
22
+
23
+ # Override this to render with multiple colors
24
+ # Should call window.addstr() for each segment with different colors
25
+ # Return true if custom rendering was done, false to use default
26
+ def render_custom(window, theme, max_width)
27
+ false
28
+ end
29
+
30
+ def disabled?
31
+ false
32
+ end
33
+
34
+ def activate
35
+ # Override in subclasses
36
+ end
37
+
38
+ def handle_key(ch)
39
+ false
40
+ end
41
+ end
42
+
43
+ # Action item - executes callback when activated
44
+ class ActionItem < ListItem
45
+ def initialize(text, value: nil, color: nil, &action)
46
+ super(text, value: value, color: color)
47
+ @action = action
48
+ end
49
+
50
+ def activate
51
+ @action&.call(self)
52
+ end
53
+ end
54
+
55
+ # Disabled/greyed item
56
+ class DisabledItem < ListItem
57
+ def disabled?
58
+ true
59
+ end
60
+ end
61
+
62
+ # Text input item - allows inline text editing
63
+ class InputItem < ListItem
64
+ attr_accessor :input_value
65
+
66
+ def initialize(label, default: "", &on_submit)
67
+ super(label)
68
+ @input_value = default
69
+ @on_submit = on_submit
70
+ @cursor_pos = @input_value.length
71
+ end
72
+
73
+ def display_text
74
+ cursor = "_"
75
+ "#{@text}: #{@input_value}#{cursor}"
76
+ end
77
+
78
+ def handle_key(ch)
79
+ case ch
80
+ when *Keys::ENTERS
81
+ @on_submit&.call(@input_value)
82
+ true
83
+ when *Keys::BACKSPACES
84
+ if @cursor_pos > 0
85
+ @input_value[@cursor_pos - 1] = ''
86
+ @cursor_pos -= 1
87
+ end
88
+ true
89
+ when Keys::LEFT
90
+ @cursor_pos = [@cursor_pos - 1, 0].max
91
+ true
92
+ when Keys::RIGHT
93
+ @cursor_pos = [@cursor_pos + 1, @input_value.length].min
94
+ true
95
+ when Keys::SPACE..(Keys::DEL_ASCII - 1) # Printable ASCII
96
+ @input_value.insert(@cursor_pos, ch.chr)
97
+ @cursor_pos += 1
98
+ true
99
+ else
100
+ false
101
+ end
102
+ end
103
+ end
104
+
105
+ # Separator - visual divider, not selectable
106
+ class SeparatorItem < ListItem
107
+ def initialize(text = "")
108
+ super(text)
109
+ end
110
+
111
+ def disabled?
112
+ true
113
+ end
114
+
115
+ def display_text
116
+ @text.empty? ? ("\u2500" * 40) : @text
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'container'
4
+ require_relative '../border'
5
+
6
+ module Potty
7
+ module Widgets
8
+ # A bordered, optionally titled container. Stacks its children
9
+ # vertically inside a one-cell border frame.
10
+ class Panel < Container
11
+ attr_accessor :title, :style, :color
12
+
13
+ def initialize(app, title: nil, style: :single, color: :normal, spacing: 0)
14
+ super(app, spacing: spacing)
15
+ @title = title
16
+ @style = style
17
+ @color = color
18
+ end
19
+
20
+ def preferred_height(width)
21
+ inner_w = [width - 2, 0].max
22
+ inner = if @children.empty?
23
+ 0
24
+ else
25
+ @children.sum { |c| c.preferred_height(inner_w) } + @spacing * (@children.size - 1)
26
+ end
27
+ inner + 2 # top + bottom border
28
+ end
29
+
30
+ def layout_children
31
+ inner = Layout::Rect.new(
32
+ @rect.x + 1,
33
+ @rect.y + 1,
34
+ [@rect.width - 2, 0].max,
35
+ [@rect.height - 2, 0].max
36
+ )
37
+ rects = Layout.stack(inner, @children, spacing: @spacing)
38
+ @children.zip(rects).each { |child, rect| child.layout(rect) }
39
+ end
40
+
41
+ def render(window)
42
+ return unless @visible && @rect
43
+
44
+ Border.draw(window, @rect, style: @style, attr: theme[@color], title: @title)
45
+ super # render children inside the frame
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Potty
4
+ module Widgets
5
+ # High-fidelity progress bar using Unicode block elements
6
+ # Provides 8x more granularity than simple filled/empty characters
7
+ # Pure string rendering - usable on a curses window or plain stdout
8
+ class ProgressBar
9
+ BLOCKS = [
10
+ ' ',
11
+ "\u258F", # 1/8
12
+ "\u258E", # 2/8
13
+ "\u258D", # 3/8
14
+ "\u258C", # 4/8
15
+ "\u258B", # 5/8
16
+ "\u258A", # 6/8
17
+ "\u2589", # 7/8
18
+ "\u2588" # Full
19
+ ].freeze
20
+
21
+ attr_reader :width
22
+
23
+ def initialize(width: 20)
24
+ @width = width
25
+ end
26
+
27
+ # Generate progress bar string
28
+ # @param progress [Float] Progress from 0.0 to 1.0
29
+ # @return [String] The rendered progress bar
30
+ def render(progress)
31
+ progress = [[progress, 0.0].max, 1.0].min
32
+
33
+ total_units = @width * 8
34
+ filled_units = (progress * total_units).round
35
+
36
+ full_blocks = filled_units / 8
37
+ partial_eighths = filled_units % 8
38
+
39
+ bar = BLOCKS[8] * full_blocks
40
+
41
+ if full_blocks < @width
42
+ bar += BLOCKS[partial_eighths]
43
+ bar += ' ' * (@width - full_blocks - 1)
44
+ end
45
+
46
+ bar
47
+ end
48
+
49
+ # Render with brackets
50
+ def render_with_brackets(progress)
51
+ "[#{render(progress)}]"
52
+ end
53
+ end
54
+ end
55
+ end