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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/bin/potty_demo +128 -0
- data/examples/test_view.rb +87 -0
- data/lib/potty/animator.rb +127 -0
- data/lib/potty/application.rb +136 -0
- data/lib/potty/border.rb +51 -0
- data/lib/potty/events.rb +46 -0
- data/lib/potty/keys.rb +71 -0
- data/lib/potty/layout.rb +47 -0
- data/lib/potty/sprite.rb +49 -0
- data/lib/potty/sprites/sample.rb +36 -0
- data/lib/potty/style.rb +14 -0
- data/lib/potty/surface.rb +46 -0
- data/lib/potty/surfaces/curses_surface.rb +114 -0
- data/lib/potty/surfaces/inline_surface.rb +148 -0
- data/lib/potty/theme.rb +82 -0
- data/lib/potty/version.rb +5 -0
- data/lib/potty/view.rb +132 -0
- data/lib/potty/widgets/base.rb +114 -0
- data/lib/potty/widgets/button.rb +52 -0
- data/lib/potty/widgets/checkbox_group.rb +101 -0
- data/lib/potty/widgets/colored_fields_item.rb +56 -0
- data/lib/potty/widgets/container.rb +113 -0
- data/lib/potty/widgets/countdown.rb +81 -0
- data/lib/potty/widgets/flash_message.rb +69 -0
- data/lib/potty/widgets/label.rb +37 -0
- data/lib/potty/widgets/list.rb +192 -0
- data/lib/potty/widgets/list_item.rb +120 -0
- data/lib/potty/widgets/panel.rb +49 -0
- data/lib/potty/widgets/progress_bar.rb +55 -0
- data/lib/potty/widgets/radio_group.rb +121 -0
- data/lib/potty/widgets/spinner.rb +84 -0
- data/lib/potty/widgets/status_bar.rb +56 -0
- data/lib/potty/widgets/text_input.rb +138 -0
- data/lib/potty/widgets/toggle.rb +65 -0
- data/lib/potty/window_manager.rb +55 -0
- data/lib/potty.rb +35 -0
- 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
|