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