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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../surface'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
module Surfaces
|
|
7
|
+
# Renders an N-line region in place under the cursor — like docker
|
|
8
|
+
# compose / npm / cargo progress — instead of taking over the screen.
|
|
9
|
+
# No init_screen, no alt-screen; the terminal stays in cooked mode, so
|
|
10
|
+
# Ctrl-C behaves normally and input is left alone (passive widgets only).
|
|
11
|
+
#
|
|
12
|
+
# Model: a small cell grid (rows x cols). Widgets draw into it via the
|
|
13
|
+
# same setpos/addstr/attron calls they use on a curses surface; present
|
|
14
|
+
# repaints the region with ANSI (carriage-return + clear-line per row,
|
|
15
|
+
# then cursor back to the top). finalize freezes the last frame and drops
|
|
16
|
+
# the cursor to the line below so the next prompt lands cleanly.
|
|
17
|
+
class InlineSurface < Surface
|
|
18
|
+
SGR_FG = {
|
|
19
|
+
default: 39, black: 30, red: 31, green: 32, yellow: 33,
|
|
20
|
+
blue: 34, magenta: 35, cyan: 36, white: 37, bright_black: 90
|
|
21
|
+
}.freeze
|
|
22
|
+
SGR_BG = {
|
|
23
|
+
default: 49, black: 40, red: 41, green: 42, yellow: 43,
|
|
24
|
+
blue: 44, magenta: 45, cyan: 46, white: 47, bright_black: 100
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(theme:, lines: nil, tick_interval: 40, out: $stdout)
|
|
28
|
+
super()
|
|
29
|
+
@theme = theme
|
|
30
|
+
@rows = [lines || 1, 1].max
|
|
31
|
+
@tick_interval = tick_interval
|
|
32
|
+
@out = out
|
|
33
|
+
@cols = detect_cols
|
|
34
|
+
@cursor = [0, 0]
|
|
35
|
+
@cur_style = nil
|
|
36
|
+
@primed = false
|
|
37
|
+
erase
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def size
|
|
41
|
+
[@rows, @cols]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start
|
|
45
|
+
@out.write("\e[?25l") # hide cursor
|
|
46
|
+
@out.flush
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finalize
|
|
50
|
+
present # freeze the final frame
|
|
51
|
+
@out.write("\n") # drop below the region
|
|
52
|
+
@out.write("\e[?25h") # restore cursor
|
|
53
|
+
@out.flush
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def erase
|
|
57
|
+
@cells = Array.new(@rows) { Array.new(@cols) { [' ', nil] } }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def setpos(row, col)
|
|
61
|
+
@cursor = [row, col]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def addstr(str)
|
|
65
|
+
row, col = @cursor
|
|
66
|
+
return unless row.between?(0, @rows - 1)
|
|
67
|
+
|
|
68
|
+
str.to_s.each_char do |ch|
|
|
69
|
+
break if col >= @cols
|
|
70
|
+
|
|
71
|
+
@cells[row][col] = [ch, @cur_style] if col >= 0
|
|
72
|
+
col += 1
|
|
73
|
+
end
|
|
74
|
+
@cursor = [row, col]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def attron(style)
|
|
78
|
+
prev = @cur_style
|
|
79
|
+
@cur_style = style.is_a?(Potty::Style) ? style : nil
|
|
80
|
+
yield if block_given?
|
|
81
|
+
ensure
|
|
82
|
+
@cur_style = prev
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def present
|
|
86
|
+
if @primed
|
|
87
|
+
@out.write("\e[#{@rows - 1}A") if @rows > 1 # back to the top row
|
|
88
|
+
else
|
|
89
|
+
@primed = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@rows.times do |i|
|
|
93
|
+
@out.write("\r\e[2K") # carriage return + clear line
|
|
94
|
+
@out.write(render_row(@cells[i]))
|
|
95
|
+
@out.write("\n") unless i == @rows - 1
|
|
96
|
+
end
|
|
97
|
+
@out.flush
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Inline mode ignores input; sleeping here gives the loop its tick
|
|
101
|
+
# cadence. Terminal stays cooked, so Ctrl-C raises Interrupt normally.
|
|
102
|
+
def read_key
|
|
103
|
+
sleep(@tick_interval / 1000.0) if @tick_interval
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def render_row(cells)
|
|
110
|
+
last = cells.rindex { |ch, _| ch != ' ' } || -1
|
|
111
|
+
return '' if last.negative?
|
|
112
|
+
|
|
113
|
+
out = +''
|
|
114
|
+
emitted = nil
|
|
115
|
+
(0..last).each do |i|
|
|
116
|
+
ch, style = cells[i]
|
|
117
|
+
if style != emitted
|
|
118
|
+
out << sgr(style)
|
|
119
|
+
emitted = style
|
|
120
|
+
end
|
|
121
|
+
out << ch
|
|
122
|
+
end
|
|
123
|
+
out << "\e[0m" if emitted
|
|
124
|
+
out
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def sgr(style)
|
|
128
|
+
return "\e[0m" if style.nil?
|
|
129
|
+
|
|
130
|
+
codes = []
|
|
131
|
+
codes << 1 if style.bold?
|
|
132
|
+
codes << 4 if style.underline?
|
|
133
|
+
codes << 7 if style.reverse?
|
|
134
|
+
codes << SGR_FG.fetch(style.fg, 39)
|
|
135
|
+
codes << SGR_BG.fetch(style.bg, 49)
|
|
136
|
+
"\e[#{codes.join(';')}m"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def detect_cols
|
|
140
|
+
return @out.winsize[1] if @out.respond_to?(:winsize) && @out.tty?
|
|
141
|
+
|
|
142
|
+
(ENV['COLUMNS'] || 80).to_i
|
|
143
|
+
rescue StandardError
|
|
144
|
+
80
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/potty/theme.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require_relative 'style'
|
|
5
|
+
|
|
6
|
+
module Potty
|
|
7
|
+
# Theme maps semantic names to colours, and speaks two dialects:
|
|
8
|
+
#
|
|
9
|
+
# theme.style(:info) => a Style (symbolic, render-target-agnostic) —
|
|
10
|
+
# the path Surfaces resolve, for curses OR inline.
|
|
11
|
+
# theme[:info] => a curses attribute integer (a colour pair) —
|
|
12
|
+
# back-compat for code that draws straight to a
|
|
13
|
+
# Curses window. Only meaningful once curses is up.
|
|
14
|
+
#
|
|
15
|
+
# The symbolic PALETTE is the source of truth; the curses pairs are derived
|
|
16
|
+
# from it in setup_colors, so the two dialects never drift.
|
|
17
|
+
class Theme
|
|
18
|
+
# Symbolic colour names -> curses colour numbers (-1 = terminal default).
|
|
19
|
+
COLORS = {
|
|
20
|
+
default: -1,
|
|
21
|
+
black: ::Curses::COLOR_BLACK, red: ::Curses::COLOR_RED,
|
|
22
|
+
green: ::Curses::COLOR_GREEN, yellow: ::Curses::COLOR_YELLOW,
|
|
23
|
+
blue: ::Curses::COLOR_BLUE, magenta: ::Curses::COLOR_MAGENTA,
|
|
24
|
+
cyan: ::Curses::COLOR_CYAN, white: ::Curses::COLOR_WHITE,
|
|
25
|
+
bright_black: 8
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# name -> { fg:, bg: } in symbolic colours. Body text inherits the
|
|
29
|
+
# terminal's own colours (:default) so potty blends into any theme;
|
|
30
|
+
# only deliberate highlights carry an explicit background.
|
|
31
|
+
PALETTE = {
|
|
32
|
+
normal: { fg: :default, bg: :default },
|
|
33
|
+
selected: { fg: :black, bg: :green },
|
|
34
|
+
disabled: { fg: :bright_black, bg: :default },
|
|
35
|
+
success: { fg: :green, bg: :default },
|
|
36
|
+
error: { fg: :red, bg: :default },
|
|
37
|
+
warning: { fg: :yellow, bg: :default },
|
|
38
|
+
info: { fg: :cyan, bg: :default },
|
|
39
|
+
dim: { fg: :bright_black, bg: :default },
|
|
40
|
+
header: { fg: :white, bg: :blue },
|
|
41
|
+
status: { fg: :black, bg: :cyan }
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :palette, :colors
|
|
45
|
+
|
|
46
|
+
# Pass a partial palette ({ name => { fg:, bg: } }) to override entries.
|
|
47
|
+
def initialize(palette = nil)
|
|
48
|
+
@palette = palette ? PALETTE.merge(palette) : PALETTE
|
|
49
|
+
@colors = {}
|
|
50
|
+
setup_colors if ::Curses.has_colors?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Allocate a curses colour pair per palette entry (curses mode only).
|
|
54
|
+
def setup_colors
|
|
55
|
+
::Curses.start_color
|
|
56
|
+
::Curses.use_default_colors
|
|
57
|
+
@palette.each_with_index do |(name, c), idx|
|
|
58
|
+
pair = idx + 1
|
|
59
|
+
::Curses.init_pair(pair, COLORS.fetch(c[:fg], -1), COLORS.fetch(c[:bg], -1))
|
|
60
|
+
@colors[name] = ::Curses.color_pair(pair)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Semantic style — symbolic colours + attributes, resolved by a Surface.
|
|
65
|
+
def style(name, bold: false, underline: false, reverse: false)
|
|
66
|
+
c = @palette[name] || @palette[:normal]
|
|
67
|
+
Style.new(fg: c[:fg], bg: c[:bg], bold: bold, underline: underline, reverse: reverse)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Curses attribute integer (back-compat for direct-to-window drawing).
|
|
71
|
+
def [](name)
|
|
72
|
+
@colors[name] || @colors[:normal] || 0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def attr(name, bold: false, underline: false)
|
|
76
|
+
a = self[name]
|
|
77
|
+
a |= ::Curses::A_BOLD if bold
|
|
78
|
+
a |= ::Curses::A_UNDERLINE if underline
|
|
79
|
+
a
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/potty/view.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'layout'
|
|
4
|
+
require_relative 'keys'
|
|
5
|
+
|
|
6
|
+
module Potty
|
|
7
|
+
# Base class for views
|
|
8
|
+
class View
|
|
9
|
+
attr_reader :app, :widgets
|
|
10
|
+
|
|
11
|
+
def initialize(app)
|
|
12
|
+
@app = app
|
|
13
|
+
@widgets = []
|
|
14
|
+
@focused_index = 0
|
|
15
|
+
build_layout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def activate(app)
|
|
19
|
+
@app = app
|
|
20
|
+
on_activate
|
|
21
|
+
layout_widgets
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def deactivate
|
|
25
|
+
on_deactivate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def on_activate; end
|
|
29
|
+
def on_deactivate; end
|
|
30
|
+
|
|
31
|
+
# Override to build widget tree
|
|
32
|
+
def build_layout
|
|
33
|
+
# Override in subclasses
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def layout_widgets
|
|
37
|
+
rows, cols = @app.surface.size
|
|
38
|
+
container = Layout::Rect.new(0, 0, cols, rows)
|
|
39
|
+
|
|
40
|
+
# Simple stack layout with spacing
|
|
41
|
+
rects = Layout.stack(container, @widgets, spacing: 1)
|
|
42
|
+
@widgets.zip(rects).each do |widget, rect|
|
|
43
|
+
widget.layout(rect)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Draw the widget tree onto the application's surface. The surface frame
|
|
48
|
+
# (erase/present) is owned by Application#refresh_all, so this just paints.
|
|
49
|
+
def render
|
|
50
|
+
surface = @app.surface
|
|
51
|
+
@widgets.each do |widget|
|
|
52
|
+
widget.render(surface)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fan a time tick out to top-level widgets. Driven by Application#tick
|
|
57
|
+
# when a tick_interval is set. `now` is one Time read per frame.
|
|
58
|
+
def tick(now)
|
|
59
|
+
@widgets.each { |widget| widget.tick(now) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_key(ch)
|
|
63
|
+
# Delegate to focused widget first
|
|
64
|
+
return true if focused_widget&.handle_key(ch)
|
|
65
|
+
|
|
66
|
+
# Handle view-level keys
|
|
67
|
+
case ch
|
|
68
|
+
when Keys::TAB
|
|
69
|
+
cycle_focus(1)
|
|
70
|
+
true
|
|
71
|
+
when Keys::SHIFT_TAB
|
|
72
|
+
cycle_focus(-1)
|
|
73
|
+
true
|
|
74
|
+
else
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_escape
|
|
80
|
+
false # Return true if handled
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
protected
|
|
84
|
+
|
|
85
|
+
def flash_success(message)
|
|
86
|
+
flash_widget&.show(message, type: :success)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def flash_error(message)
|
|
90
|
+
flash_widget&.show(message, type: :error)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def flash_info(message)
|
|
94
|
+
flash_widget&.show(message, type: :info)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def flash_widget
|
|
98
|
+
@widgets.find { |w| w.is_a?(Widgets::FlashMessage) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Focusable leaves in visual order, recursing into containers so a
|
|
104
|
+
# nested layout (VBox/HBox/Panel) still cycles correctly with Tab.
|
|
105
|
+
def focusable_widgets
|
|
106
|
+
@widgets.flat_map do |w|
|
|
107
|
+
if w.is_a?(Widgets::Container)
|
|
108
|
+
w.focusable_widgets
|
|
109
|
+
elsif w.can_focus?
|
|
110
|
+
[w]
|
|
111
|
+
else
|
|
112
|
+
[]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def cycle_focus(delta)
|
|
118
|
+
focusable = focusable_widgets
|
|
119
|
+
return if focusable.empty?
|
|
120
|
+
|
|
121
|
+
current = focusable.index(focused_widget) || 0
|
|
122
|
+
new_index = (current + delta) % focusable.size
|
|
123
|
+
|
|
124
|
+
focused_widget&.blur
|
|
125
|
+
focusable[new_index].focus
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def focused_widget
|
|
129
|
+
focusable_widgets.find(&:focused)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../events'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
module Widgets
|
|
7
|
+
# Base class for all widgets
|
|
8
|
+
class Base
|
|
9
|
+
include Events
|
|
10
|
+
|
|
11
|
+
attr_accessor :rect, :parent, :focused
|
|
12
|
+
attr_reader :app
|
|
13
|
+
|
|
14
|
+
def initialize(app)
|
|
15
|
+
@app = app
|
|
16
|
+
@rect = nil
|
|
17
|
+
@parent = nil
|
|
18
|
+
@focused = false
|
|
19
|
+
@visible = true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Lifecycle
|
|
23
|
+
def activate
|
|
24
|
+
# Override in subclasses
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def deactivate
|
|
28
|
+
# Override in subclasses
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Layout
|
|
32
|
+
def preferred_height(width)
|
|
33
|
+
1 # Default to single line
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def layout(rect)
|
|
37
|
+
@rect = rect
|
|
38
|
+
on_layout
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def on_layout
|
|
42
|
+
# Override for custom layout logic
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Rendering
|
|
46
|
+
def render(window)
|
|
47
|
+
return unless @visible && @rect
|
|
48
|
+
# Override in subclasses
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Time-based update hook. Called once per event-loop frame when the
|
|
52
|
+
# Application has a tick_interval set. `now` is a single Time read
|
|
53
|
+
# shared across all widgets in the frame (so playback stays in sync
|
|
54
|
+
# and is deterministic to unit-test). Override in time-driven widgets
|
|
55
|
+
# such as Animator and Countdown.
|
|
56
|
+
def tick(now)
|
|
57
|
+
# Override in time-driven subclasses
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Input
|
|
61
|
+
def handle_key(ch)
|
|
62
|
+
false # Return true if handled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_escape
|
|
66
|
+
false # Return true if handled
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Focus
|
|
70
|
+
def can_focus?
|
|
71
|
+
false # Override in interactive widgets
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def focus
|
|
75
|
+
@focused = true
|
|
76
|
+
on_focus
|
|
77
|
+
emit(:focus, self)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def blur
|
|
81
|
+
@focused = false
|
|
82
|
+
on_blur
|
|
83
|
+
emit(:blur, self)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_focus; end
|
|
87
|
+
def on_blur; end
|
|
88
|
+
|
|
89
|
+
# Visibility
|
|
90
|
+
def visible?
|
|
91
|
+
@visible
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def show
|
|
95
|
+
@visible = true
|
|
96
|
+
self
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def hide
|
|
100
|
+
@visible = false
|
|
101
|
+
self
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def visible=(flag)
|
|
105
|
+
flag ? show : hide
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Helpers
|
|
109
|
+
def theme
|
|
110
|
+
@app.theme
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../keys'
|
|
5
|
+
|
|
6
|
+
module Potty
|
|
7
|
+
module Widgets
|
|
8
|
+
# Focusable push button. Space/Enter fires :press. Pass on_press: for a
|
|
9
|
+
# one-liner, or wire it with button.on(:press) { ... }.
|
|
10
|
+
class Button < Base
|
|
11
|
+
attr_accessor :label, :color
|
|
12
|
+
|
|
13
|
+
def initialize(app, label: '', color: :normal, on_press: nil)
|
|
14
|
+
super(app)
|
|
15
|
+
@label = label
|
|
16
|
+
@color = color
|
|
17
|
+
on(:press, &on_press) if on_press
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def can_focus?
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def preferred_height(_width)
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def press
|
|
29
|
+
emit(:press, self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def handle_key(ch)
|
|
33
|
+
case ch
|
|
34
|
+
when Keys::SPACE, *Keys::ENTERS
|
|
35
|
+
press
|
|
36
|
+
true
|
|
37
|
+
else
|
|
38
|
+
false
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render(window)
|
|
43
|
+
return unless @visible && @rect
|
|
44
|
+
|
|
45
|
+
text = "[ #{@label} ]"[0, @rect.width]
|
|
46
|
+
attr = @focused ? theme.attr(:selected, bold: true) : theme[@color]
|
|
47
|
+
window.setpos(@rect.y, @rect.x)
|
|
48
|
+
window.attron(attr) { window.addstr(text) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../keys'
|
|
6
|
+
|
|
7
|
+
module Potty
|
|
8
|
+
module Widgets
|
|
9
|
+
# Multi-select list of options, one row each. Up/down move a cursor,
|
|
10
|
+
# Space/Enter toggles the option under it. The RadioGroup sibling for
|
|
11
|
+
# "choose any number". Emits :change(selected_values) on each toggle.
|
|
12
|
+
class CheckboxGroup < Base
|
|
13
|
+
attr_accessor :on_change
|
|
14
|
+
|
|
15
|
+
def initialize(app, options: [], selected: [], on_change: nil)
|
|
16
|
+
super(app)
|
|
17
|
+
@options = normalize(options)
|
|
18
|
+
@selected = Array(selected).dup
|
|
19
|
+
@cursor = 0
|
|
20
|
+
@on_change = on_change
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def can_focus?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def options
|
|
28
|
+
@options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A snapshot of the selected values (safe to store).
|
|
32
|
+
def selected
|
|
33
|
+
@selected.dup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def selected?(value)
|
|
37
|
+
@selected.include?(value)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def preferred_height(_width)
|
|
41
|
+
@options.size
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def handle_key(ch)
|
|
45
|
+
case ch
|
|
46
|
+
when Keys::UP
|
|
47
|
+
move(-1)
|
|
48
|
+
when Keys::DOWN
|
|
49
|
+
move(1)
|
|
50
|
+
when Keys::SPACE, *Keys::ENTERS
|
|
51
|
+
toggle(@cursor)
|
|
52
|
+
else
|
|
53
|
+
return false
|
|
54
|
+
end
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render(window)
|
|
59
|
+
return unless @visible && @rect
|
|
60
|
+
|
|
61
|
+
@options.each_with_index do |opt, i|
|
|
62
|
+
break if i >= @rect.height
|
|
63
|
+
|
|
64
|
+
mark = selected?(opt[:value]) ? "[\u2713]" : "[ ]"
|
|
65
|
+
on_cursor = @focused && i == @cursor
|
|
66
|
+
attr = on_cursor ? theme.attr(:selected, bold: true) : theme[:normal]
|
|
67
|
+
window.setpos(@rect.y + i, @rect.x)
|
|
68
|
+
window.attron(attr) { window.addstr("#{mark} #{opt[:label]}"[0, @rect.width]) }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def normalize(opts)
|
|
75
|
+
(opts || []).map do |o|
|
|
76
|
+
if o.is_a?(Hash)
|
|
77
|
+
{ value: o[:value], label: (o[:label] || o[:value]).to_s }
|
|
78
|
+
else
|
|
79
|
+
{ value: o, label: o.to_s }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def move(delta)
|
|
85
|
+
return if @options.empty?
|
|
86
|
+
|
|
87
|
+
@cursor = (@cursor + delta) % @options.size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def toggle(idx)
|
|
91
|
+
opt = @options[idx]
|
|
92
|
+
return unless opt
|
|
93
|
+
|
|
94
|
+
value = opt[:value]
|
|
95
|
+
selected?(value) ? @selected.delete(value) : (@selected << value)
|
|
96
|
+
@on_change&.call(selected)
|
|
97
|
+
emit(:change, selected)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'list_item'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
module Widgets
|
|
7
|
+
# Generic multi-color list item
|
|
8
|
+
# Takes an array of segments, each with text and color, and renders them inline
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# item = ColoredFieldsItem.new(
|
|
12
|
+
# fields: [
|
|
13
|
+
# { text: "[M]", color: :success },
|
|
14
|
+
# { text: " " },
|
|
15
|
+
# { text: "[0]", color: :dim },
|
|
16
|
+
# { text: " /path/to/backup", color: :normal }
|
|
17
|
+
# ],
|
|
18
|
+
# value: some_object
|
|
19
|
+
# ) { |item| handle_activation(item) }
|
|
20
|
+
#
|
|
21
|
+
class ColoredFieldsItem < ActionItem
|
|
22
|
+
attr_reader :fields
|
|
23
|
+
|
|
24
|
+
def initialize(fields:, value: nil, &action)
|
|
25
|
+
@fields = fields
|
|
26
|
+
super("", value: value, &action)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def render_custom(window, theme, max_width)
|
|
30
|
+
remaining = max_width
|
|
31
|
+
@fields.each do |field|
|
|
32
|
+
break if remaining <= 0
|
|
33
|
+
|
|
34
|
+
text = field[:text] || ""
|
|
35
|
+
color = field[:color]
|
|
36
|
+
bold = field[:bold] || false
|
|
37
|
+
|
|
38
|
+
text = text[0, remaining]
|
|
39
|
+
|
|
40
|
+
if color
|
|
41
|
+
attr = bold ? theme.attr(color, bold: true) : theme[color]
|
|
42
|
+
window.attron(attr) do
|
|
43
|
+
window.addstr(text)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
window.addstr(text)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
remaining -= text.length
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|