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,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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Potty
4
+ VERSION = '0.0.1'
5
+ 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