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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require_relative 'theme'
|
|
5
|
+
require_relative 'window_manager'
|
|
6
|
+
require_relative 'layout'
|
|
7
|
+
require_relative 'keys'
|
|
8
|
+
require_relative 'surface'
|
|
9
|
+
require_relative 'surfaces/curses_surface'
|
|
10
|
+
require_relative 'surfaces/inline_surface'
|
|
11
|
+
|
|
12
|
+
module Potty
|
|
13
|
+
# Main application wrapper: owns the view stack, the tick loop, and a
|
|
14
|
+
# Surface (the render target). Mode picks the surface:
|
|
15
|
+
# :curses (default) — full-screen curses display, input via getch.
|
|
16
|
+
# :inline — N lines redrawn in place under the cursor via ANSI,
|
|
17
|
+
# no init_screen, terminal stays cooked (input
|
|
18
|
+
# ignored; host drives quit). Good for progress UIs.
|
|
19
|
+
class Application
|
|
20
|
+
attr_reader :theme, :window_manager, :view_stack, :surface
|
|
21
|
+
|
|
22
|
+
# When set (milliseconds), the event loop wakes every interval even
|
|
23
|
+
# without input, advancing time-based widgets (Animator, Countdown).
|
|
24
|
+
# Leave nil for a purely blocking, input-driven loop (the default).
|
|
25
|
+
# ~33-50ms gives smooth animation. Required for :inline.
|
|
26
|
+
attr_accessor :tick_interval
|
|
27
|
+
|
|
28
|
+
def initialize(mode: :curses, lines: nil, theme: nil)
|
|
29
|
+
@view_stack = []
|
|
30
|
+
@running = false
|
|
31
|
+
@theme = theme || Theme.new
|
|
32
|
+
@mode = mode
|
|
33
|
+
@lines = lines
|
|
34
|
+
# Kept for back-compat: curses-mode consumers that draw straight to
|
|
35
|
+
# window_manager.stdscr or read its dimensions.
|
|
36
|
+
@window_manager = (mode == :curses ? WindowManager.new : nil)
|
|
37
|
+
@surface = nil
|
|
38
|
+
@tick_interval = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Start the application with root view
|
|
42
|
+
def run(root_view)
|
|
43
|
+
@surface = build_surface
|
|
44
|
+
@surface.start
|
|
45
|
+
push_view(root_view)
|
|
46
|
+
@running = true
|
|
47
|
+
event_loop
|
|
48
|
+
ensure
|
|
49
|
+
@surface&.finalize
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# View navigation
|
|
53
|
+
def push_view(view)
|
|
54
|
+
@view_stack.last&.deactivate
|
|
55
|
+
@view_stack.push(view)
|
|
56
|
+
view.activate(self)
|
|
57
|
+
refresh_all
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def pop_view
|
|
61
|
+
return if @view_stack.size <= 1
|
|
62
|
+
|
|
63
|
+
view = @view_stack.pop
|
|
64
|
+
view.deactivate
|
|
65
|
+
@view_stack.last&.activate(self)
|
|
66
|
+
refresh_all
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def quit
|
|
70
|
+
@running = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def refresh_all
|
|
74
|
+
@surface.erase
|
|
75
|
+
current_view&.render
|
|
76
|
+
@surface.present
|
|
77
|
+
end
|
|
78
|
+
alias redraw refresh_all
|
|
79
|
+
|
|
80
|
+
# Advance time-based widgets and repaint. Called automatically each
|
|
81
|
+
# event-loop frame; also public so a host that drives its own loop can
|
|
82
|
+
# pump animation/countdowns itself.
|
|
83
|
+
def tick
|
|
84
|
+
current_view&.tick(Time.now)
|
|
85
|
+
refresh_all
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Suspend the surface for an external process (e.g., shelling out).
|
|
89
|
+
def suspend
|
|
90
|
+
@surface&.finalize
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Resume after suspension.
|
|
94
|
+
def resume
|
|
95
|
+
@surface&.start
|
|
96
|
+
current_view&.activate(self)
|
|
97
|
+
refresh_all
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def build_surface
|
|
103
|
+
case @mode
|
|
104
|
+
when :inline
|
|
105
|
+
Surfaces::InlineSurface.new(theme: @theme, lines: @lines, tick_interval: @tick_interval || 40)
|
|
106
|
+
else
|
|
107
|
+
Surfaces::CursesSurface.new(@window_manager, @theme, tick_interval: @tick_interval)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def event_loop
|
|
112
|
+
while @running
|
|
113
|
+
ch = @surface.read_key
|
|
114
|
+
|
|
115
|
+
case ch
|
|
116
|
+
when nil # tick timeout / no input this cycle
|
|
117
|
+
# fall through to tick
|
|
118
|
+
when Keys::CTRL_C
|
|
119
|
+
raise Interrupt
|
|
120
|
+
when Keys::ESC
|
|
121
|
+
unless current_view&.handle_escape
|
|
122
|
+
pop_view if @view_stack.size > 1
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
current_view&.handle_key(ch)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
tick
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def current_view
|
|
133
|
+
@view_stack.last
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
data/lib/potty/border.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# Box-drawing helper shared by any widget that needs a frame (List,
|
|
5
|
+
# Panel, Modal, etc.) instead of each one hand-rolling corner glyphs.
|
|
6
|
+
module Border
|
|
7
|
+
STYLES = {
|
|
8
|
+
single: { tl: "\u250C", tr: "\u2510", bl: "\u2514", br: "\u2518", h: "\u2500", v: "\u2502" },
|
|
9
|
+
rounded: { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" },
|
|
10
|
+
double: { tl: "\u2554", tr: "\u2557", bl: "\u255A", br: "\u255D", h: "\u2550", v: "\u2551" },
|
|
11
|
+
heavy: { tl: "\u250F", tr: "\u2513", bl: "\u2517", br: "\u251B", h: "\u2501", v: "\u2503" }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Draw a border around rect on window. `attr` is a resolved curses
|
|
17
|
+
# attribute (e.g. theme[:dim]); `title`, if given, is centered on the
|
|
18
|
+
# top edge.
|
|
19
|
+
def draw(window, rect, style: :single, attr: 0, title: nil)
|
|
20
|
+
return if rect.width < 2 || rect.height < 2
|
|
21
|
+
|
|
22
|
+
s = STYLES[style] || STYLES[:single]
|
|
23
|
+
inner = rect.width - 2
|
|
24
|
+
|
|
25
|
+
window.attron(attr) do
|
|
26
|
+
window.setpos(rect.y, rect.x)
|
|
27
|
+
window.addstr(s[:tl] + s[:h] * inner + s[:tr])
|
|
28
|
+
|
|
29
|
+
(1...(rect.height - 1)).each do |dy|
|
|
30
|
+
window.setpos(rect.y + dy, rect.x)
|
|
31
|
+
window.addstr(s[:v])
|
|
32
|
+
window.setpos(rect.y + dy, rect.x + rect.width - 1)
|
|
33
|
+
window.addstr(s[:v])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
window.setpos(rect.y + rect.height - 1, rect.x)
|
|
37
|
+
window.addstr(s[:bl] + s[:h] * inner + s[:br])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
draw_title(window, rect, title, attr, inner) if title && !title.to_s.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def draw_title(window, rect, title, attr, inner)
|
|
44
|
+
label = " #{title} "
|
|
45
|
+
label = label[0, inner] || '' if label.length > inner
|
|
46
|
+
x = rect.x + 1 + [(inner - label.length) / 2, 0].max
|
|
47
|
+
window.setpos(rect.y, x)
|
|
48
|
+
window.attron(attr) { window.addstr(label) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/potty/events.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# A tiny event-emitter mixin so widgets can be wired together
|
|
5
|
+
# declaratively. Any includer gains `on`/`off`/`emit`. Widgets emit
|
|
6
|
+
# semantic events (`:change`, `:focus`, `:select`, `:press`, …) that a
|
|
7
|
+
# consumer subscribes to with blocks — letting a View stitch its pieces
|
|
8
|
+
# together without each widget needing a bespoke callback setter:
|
|
9
|
+
#
|
|
10
|
+
# input.on(:change) { |text| preview.text = text }
|
|
11
|
+
# toggle.on(:change) { |on| advanced.visible = on }
|
|
12
|
+
# button.on(:press) { app.pop_view }
|
|
13
|
+
#
|
|
14
|
+
# `on` returns self, so subscriptions chain. Multiple listeners per event
|
|
15
|
+
# are supported and fire in registration order.
|
|
16
|
+
module Events
|
|
17
|
+
def on(event, &block)
|
|
18
|
+
return self unless block
|
|
19
|
+
|
|
20
|
+
(@listeners ||= {})[event.to_sym] ||= []
|
|
21
|
+
@listeners[event.to_sym] << block
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Remove listeners for one event, or all events when called bare.
|
|
26
|
+
def off(event = nil)
|
|
27
|
+
@listeners ||= {}
|
|
28
|
+
event ? @listeners.delete(event.to_sym) : @listeners.clear
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Fire an event to its listeners. Returns true if any listener ran.
|
|
33
|
+
def emit(event, *args)
|
|
34
|
+
list = (@listeners ||= {})[event.to_sym]
|
|
35
|
+
return false if list.nil? || list.empty?
|
|
36
|
+
|
|
37
|
+
list.each { |cb| cb.call(*args) }
|
|
38
|
+
true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def listeners?(event)
|
|
42
|
+
list = (@listeners ||= {})[event.to_sym]
|
|
43
|
+
!(list.nil? || list.empty?)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/potty/keys.rb
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
# Named key codes, so widget input handling reads in intent rather than
|
|
7
|
+
# magic integers. Special keys resolve through Curses with fallbacks to
|
|
8
|
+
# the conventional ncurses values, for portability across curses builds.
|
|
9
|
+
#
|
|
10
|
+
# (END_ carries a trailing underscore because END is a Ruby keyword.)
|
|
11
|
+
module Keys
|
|
12
|
+
# Control / ASCII
|
|
13
|
+
CTRL_C = 3
|
|
14
|
+
CTRL_A = 1
|
|
15
|
+
CTRL_E = 5
|
|
16
|
+
CTRL_D = 4
|
|
17
|
+
TAB = 9
|
|
18
|
+
ENTER = 10
|
|
19
|
+
RETURN = 13
|
|
20
|
+
ESC = 27
|
|
21
|
+
SPACE = 32
|
|
22
|
+
CTRL_H = 8
|
|
23
|
+
DEL_ASCII = 127
|
|
24
|
+
|
|
25
|
+
def self.const_or(path, fallback)
|
|
26
|
+
Curses::Key.const_defined?(path) ? Curses::Key.const_get(path) : fallback
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Arrows / navigation (curses-resolved with ncurses fallbacks)
|
|
30
|
+
UP = const_or(:UP, 259)
|
|
31
|
+
DOWN = const_or(:DOWN, 258)
|
|
32
|
+
LEFT = const_or(:LEFT, 260)
|
|
33
|
+
RIGHT = const_or(:RIGHT, 261)
|
|
34
|
+
HOME = const_or(:HOME, 262)
|
|
35
|
+
END_ = const_or(:END, 360)
|
|
36
|
+
DELETE = const_or(:DC, 330)
|
|
37
|
+
BACKSPACE = const_or(:BACKSPACE, 263)
|
|
38
|
+
SHIFT_TAB = const_or(:BTAB, 353)
|
|
39
|
+
RESIZE = const_or(:RESIZE, 410)
|
|
40
|
+
|
|
41
|
+
# Common groupings
|
|
42
|
+
ENTERS = [ENTER, RETURN].freeze
|
|
43
|
+
BACKSPACES = [DEL_ASCII, CTRL_H, BACKSPACE].freeze
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Normalize a curses #getch result to an integer key code. Ruby's
|
|
48
|
+
# curses returns a String for ordinary printable input and an Integer
|
|
49
|
+
# for control/function keys — normalizing here lets all widget
|
|
50
|
+
# handle_key logic rely on numeric codes. Passes nil (tick timeout)
|
|
51
|
+
# and Integers through unchanged.
|
|
52
|
+
def code(ch)
|
|
53
|
+
return ch unless ch.is_a?(String)
|
|
54
|
+
return nil if ch.empty?
|
|
55
|
+
|
|
56
|
+
ch.ord
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def enter?(ch)
|
|
60
|
+
ENTERS.include?(ch)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def backspace?(ch)
|
|
64
|
+
BACKSPACES.include?(ch)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def printable?(ch)
|
|
68
|
+
ch.is_a?(Integer) && ch >= SPACE && ch <= DEL_ASCII - 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/potty/layout.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# Layout engine for positioning and sizing widgets
|
|
5
|
+
class Layout
|
|
6
|
+
# Rectangle representing position and size
|
|
7
|
+
Rect = Struct.new(:x, :y, :width, :height) do
|
|
8
|
+
def to_s
|
|
9
|
+
"Rect(x=#{x}, y=#{y}, w=#{width}, h=#{height})"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Vertical stack layout
|
|
14
|
+
def self.stack(container_rect, widgets, spacing: 0)
|
|
15
|
+
y = container_rect.y
|
|
16
|
+
widgets.map do |widget|
|
|
17
|
+
height = widget.preferred_height(container_rect.width)
|
|
18
|
+
rect = Rect.new(container_rect.x, y, container_rect.width, height)
|
|
19
|
+
y += height + spacing
|
|
20
|
+
rect
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Horizontal split
|
|
25
|
+
def self.split_horizontal(container_rect, ratio: 0.5)
|
|
26
|
+
split_x = container_rect.x + (container_rect.width * ratio).to_i
|
|
27
|
+
left = Rect.new(
|
|
28
|
+
container_rect.x,
|
|
29
|
+
container_rect.y,
|
|
30
|
+
split_x - container_rect.x,
|
|
31
|
+
container_rect.height
|
|
32
|
+
)
|
|
33
|
+
right = Rect.new(
|
|
34
|
+
split_x,
|
|
35
|
+
container_rect.y,
|
|
36
|
+
container_rect.width - (split_x - container_rect.x),
|
|
37
|
+
container_rect.height
|
|
38
|
+
)
|
|
39
|
+
[left, right]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fill available space
|
|
43
|
+
def self.fill(container_rect)
|
|
44
|
+
container_rect.dup
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/lib/potty/sprite.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# A named sequence of multiline string frames. Pure data — holds no
|
|
5
|
+
# curses state and no timing state; an Animator drives playback.
|
|
6
|
+
#
|
|
7
|
+
# Each frame is a multiline string. Lines may differ in length and a
|
|
8
|
+
# frame may contain trailing blank lines (preserved via split("\n", -1)).
|
|
9
|
+
class Sprite
|
|
10
|
+
attr_reader :name, :frames, :fps, :mode
|
|
11
|
+
|
|
12
|
+
# name - identifier used to select this sprite on an Animator
|
|
13
|
+
# frames - array of multiline strings, one per animation frame
|
|
14
|
+
# fps - default playback rate in frames per second
|
|
15
|
+
# mode - :loop (wrap forever) or :once (stop on the last frame)
|
|
16
|
+
def initialize(name, frames:, fps: 8, mode: :loop)
|
|
17
|
+
raise ArgumentError, 'frames must not be empty' if frames.nil? || frames.empty?
|
|
18
|
+
raise ArgumentError, "mode must be :loop or :once, got #{mode.inspect}" unless %i[loop once].include?(mode)
|
|
19
|
+
|
|
20
|
+
@name = name.to_sym
|
|
21
|
+
@frames = frames.dup.freeze
|
|
22
|
+
@fps = fps
|
|
23
|
+
@mode = mode
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def frame_count
|
|
27
|
+
@frames.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def frame(index)
|
|
31
|
+
@frames[index]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Lines of a frame, preserving trailing blank lines.
|
|
35
|
+
def frame_lines(index)
|
|
36
|
+
(@frames[index] || '').split("\n", -1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Rows in the tallest frame.
|
|
40
|
+
def height
|
|
41
|
+
@frames.map { |f| f.split("\n", -1).size }.max
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Columns in the widest line across all frames.
|
|
45
|
+
def width
|
|
46
|
+
@frames.flat_map { |f| f.split("\n", -1).map(&:length) }.max
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../sprite'
|
|
4
|
+
|
|
5
|
+
module Potty
|
|
6
|
+
module Sprites
|
|
7
|
+
# Tiny demo sprites that exercise the Animator API out of the box.
|
|
8
|
+
# These are NOT the claudepilot pilot - that mascot lives in the
|
|
9
|
+
# consuming app. They exist so the primitive has something to show and
|
|
10
|
+
# so consumers have a copy-paste template.
|
|
11
|
+
module Sample
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Looping braille spinner. Single-cell, good for inline "busy" hints.
|
|
15
|
+
def spinner
|
|
16
|
+
Sprite.new(:spinner,
|
|
17
|
+
frames: ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C",
|
|
18
|
+
"\u2834", "\u2826", "\u2827", "\u2807", "\u280F"],
|
|
19
|
+
fps: 12, mode: :loop)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A little plane taxiing across the field, played once.
|
|
23
|
+
def plane
|
|
24
|
+
Sprite.new(:plane,
|
|
25
|
+
frames: [
|
|
26
|
+
"\u2708 ",
|
|
27
|
+
" \u2708 ",
|
|
28
|
+
" \u2708 ",
|
|
29
|
+
" \u2708 ",
|
|
30
|
+
" \u2708"
|
|
31
|
+
],
|
|
32
|
+
fps: 6, mode: :once)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/potty/style.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# A semantic, render-target-agnostic description of how to draw text:
|
|
5
|
+
# symbolic colours (:cyan, :default, :bright_black, …) plus attributes.
|
|
6
|
+
# A Surface resolves a Style to concrete output — curses attributes on a
|
|
7
|
+
# CursesSurface, ANSI SGR codes on an InlineSurface — so the same widget
|
|
8
|
+
# renders to either target unchanged.
|
|
9
|
+
Style = Struct.new(:fg, :bg, :bold, :underline, :reverse, keyword_init: true) do
|
|
10
|
+
def bold? = !!bold
|
|
11
|
+
def underline? = !!underline
|
|
12
|
+
def reverse? = !!reverse
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Potty
|
|
4
|
+
# Abstract render target. Widgets draw against a Surface (setpos / addstr /
|
|
5
|
+
# attron) and the concrete subclass decides how that reaches the terminal:
|
|
6
|
+
# CursesSurface paints a curses screen; InlineSurface writes ANSI to stdout.
|
|
7
|
+
# The component tree describes *what*; the surface decides *how*.
|
|
8
|
+
class Surface
|
|
9
|
+
# [rows, cols] of the drawable area.
|
|
10
|
+
def size
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Acquire / release the terminal.
|
|
15
|
+
def start; end
|
|
16
|
+
def finalize; end
|
|
17
|
+
|
|
18
|
+
# Frame lifecycle: erase the buffer, widgets draw, then present flushes.
|
|
19
|
+
def erase
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def setpos(_row, _col)
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def addstr(_str)
|
|
28
|
+
raise NotImplementedError
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Apply a style around the block's draws. Accepts a Potty::Style or a
|
|
32
|
+
# raw integer (a legacy curses attribute) — see CursesSurface.
|
|
33
|
+
def attron(_style_or_attr)
|
|
34
|
+
yield if block_given?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def present
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# One integer key code, or nil if there was no input this cycle.
|
|
42
|
+
def read_key
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'curses'
|
|
4
|
+
require_relative '../surface'
|
|
5
|
+
require_relative '../theme'
|
|
6
|
+
require_relative '../keys'
|
|
7
|
+
|
|
8
|
+
module Potty
|
|
9
|
+
module Surfaces
|
|
10
|
+
# The default render target: a full-screen curses display. Wraps the
|
|
11
|
+
# WindowManager / stdscr so existing widgets draw exactly as before.
|
|
12
|
+
#
|
|
13
|
+
# attron accepts EITHER a Potty::Style (resolved to a colour pair +
|
|
14
|
+
# attributes) OR a raw integer (a legacy curses attribute, passed
|
|
15
|
+
# through). That dual acceptance is what lets un-migrated widgets — and
|
|
16
|
+
# direct-to-window consumers like a host's own paint code — keep working
|
|
17
|
+
# untouched while new widgets go render-target-agnostic.
|
|
18
|
+
class CursesSurface < Surface
|
|
19
|
+
def initialize(window_manager, theme, tick_interval: nil)
|
|
20
|
+
super()
|
|
21
|
+
@wm = window_manager
|
|
22
|
+
@theme = theme
|
|
23
|
+
@tick_interval = tick_interval
|
|
24
|
+
@next_pair = theme.palette.size + 1 # leave 1..N for theme[]'s pairs
|
|
25
|
+
@pairs = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start
|
|
29
|
+
# See ESCDELAY notes in Theme/Application history: the env var is only
|
|
30
|
+
# honoured by newer ncurses, so also set it via Curses.ESCDELAY=.
|
|
31
|
+
ENV['ESCDELAY'] ||= '250'
|
|
32
|
+
@wm.setup(::Curses.init_screen)
|
|
33
|
+
::Curses.ESCDELAY = 250 if ::Curses.respond_to?(:ESCDELAY=)
|
|
34
|
+
::Curses.curs_set(0)
|
|
35
|
+
::Curses.noecho
|
|
36
|
+
::Curses.cbreak
|
|
37
|
+
stdscr.keypad(true)
|
|
38
|
+
stdscr.timeout = @tick_interval if @tick_interval
|
|
39
|
+
@theme.setup_colors if ::Curses.has_colors?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def finalize
|
|
43
|
+
::Curses.close_screen
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def size
|
|
47
|
+
[@wm.max_y, @wm.max_x]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def erase
|
|
51
|
+
stdscr.erase
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def setpos(row, col)
|
|
55
|
+
stdscr.setpos(row, col)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def addstr(str)
|
|
59
|
+
stdscr.addstr(str)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def attron(style_or_attr, &block)
|
|
63
|
+
stdscr.attron(curses_attr(style_or_attr), &block)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def present
|
|
67
|
+
@wm.refresh_all
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_key
|
|
71
|
+
Keys.code(stdscr.getch)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def stdscr
|
|
77
|
+
@wm.stdscr
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def curses_attr(value)
|
|
81
|
+
return value if value.is_a?(Integer) # legacy curses attribute
|
|
82
|
+
return 0 unless value.is_a?(Potty::Style)
|
|
83
|
+
|
|
84
|
+
attr = color_pair(value.fg, value.bg)
|
|
85
|
+
attr |= ::Curses::A_BOLD if value.bold?
|
|
86
|
+
attr |= ::Curses::A_UNDERLINE if value.underline?
|
|
87
|
+
attr |= ::Curses::A_REVERSE if value.reverse?
|
|
88
|
+
attr
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def color_pair(fg, bg)
|
|
92
|
+
return 0 unless ::Curses.has_colors?
|
|
93
|
+
|
|
94
|
+
(@pairs ||= seed_pairs)
|
|
95
|
+
@pairs[[fg, bg]] ||= allocate_pair(fg, bg)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Reuse the pairs Theme already allocated for its palette combos so we
|
|
99
|
+
# don't burn a second pair on every colour we share with theme[].
|
|
100
|
+
def seed_pairs
|
|
101
|
+
map = {}
|
|
102
|
+
@theme.palette.each { |name, c| map[[c[:fg], c[:bg]]] = @theme[name] }
|
|
103
|
+
map
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def allocate_pair(fg, bg)
|
|
107
|
+
n = @next_pair
|
|
108
|
+
@next_pair += 1
|
|
109
|
+
::Curses.init_pair(n, Theme::COLORS.fetch(fg, -1), Theme::COLORS.fetch(bg, -1))
|
|
110
|
+
::Curses.color_pair(n)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|