potty 0.0.1 → 0.0.2

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.
@@ -8,21 +8,18 @@ require_relative '../keys'
8
8
  module Potty
9
9
  module Surfaces
10
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.
11
+ # WindowManager / stdscr. It resolves a Style to a curses colour pair +
12
+ # attributes (allocating pairs on demand); a raw integer is passed through
13
+ # unchanged, so a host that draws to the surface with its own curses attrs
14
+ # still works.
18
15
  class CursesSurface < Surface
19
16
  def initialize(window_manager, theme, tick_interval: nil)
20
17
  super()
21
18
  @wm = window_manager
22
19
  @theme = theme
23
20
  @tick_interval = tick_interval
24
- @next_pair = theme.palette.size + 1 # leave 1..N for theme[]'s pairs
25
- @pairs = nil
21
+ @next_pair = 1
22
+ @pairs = {}
26
23
  end
27
24
 
28
25
  def start
@@ -36,7 +33,10 @@ module Potty
36
33
  ::Curses.cbreak
37
34
  stdscr.keypad(true)
38
35
  stdscr.timeout = @tick_interval if @tick_interval
39
- @theme.setup_colors if ::Curses.has_colors?
36
+ if ::Curses.has_colors?
37
+ ::Curses.start_color
38
+ ::Curses.use_default_colors # enables -1 = terminal default
39
+ end
40
40
  end
41
41
 
42
42
  def finalize
@@ -88,21 +88,15 @@ module Potty
88
88
  attr
89
89
  end
90
90
 
91
+ # Allocate (once) and cache a curses colour pair per (fg, bg) combo the
92
+ # theme's Styles use. The palette is small, so this stays well within
93
+ # the terminal's pair budget.
91
94
  def color_pair(fg, bg)
92
95
  return 0 unless ::Curses.has_colors?
93
96
 
94
- (@pairs ||= seed_pairs)
95
97
  @pairs[[fg, bg]] ||= allocate_pair(fg, bg)
96
98
  end
97
99
 
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
100
  def allocate_pair(fg, bg)
107
101
  n = @next_pair
108
102
  @next_pair += 1
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
3
4
  require_relative '../surface'
5
+ require_relative '../ansi'
6
+ require_relative '../input/decoder'
4
7
 
5
8
  module Potty
6
9
  module Surfaces
@@ -15,25 +18,21 @@ module Potty
15
18
  # then cursor back to the top). finalize freezes the last frame and drops
16
19
  # the cursor to the line below so the next prompt lands cleanly.
17
20
  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)
21
+ def initialize(theme:, lines: nil, tick_interval: 40, out: $stdout, listen: false, input: $stdin)
28
22
  super()
29
23
  @theme = theme
30
24
  @rows = [lines || 1, 1].max
31
25
  @tick_interval = tick_interval
32
26
  @out = out
27
+ @listen = listen
28
+ @input = input
33
29
  @cols = detect_cols
34
30
  @cursor = [0, 0]
35
31
  @cur_style = nil
36
32
  @primed = false
33
+ @raw = false
34
+ @decoder = (Input::Decoder.new if listen)
35
+ @queue = []
37
36
  erase
38
37
  end
39
38
 
@@ -44,11 +43,16 @@ module Potty
44
43
  def start
45
44
  @out.write("\e[?25l") # hide cursor
46
45
  @out.flush
46
+ enter_raw if @listen
47
47
  end
48
48
 
49
49
  def finalize
50
50
  present # freeze the final frame
51
- @out.write("\n") # drop below the region
51
+ restore_cooked
52
+ # Explicit CR+LF: present leaves the cursor at the end of the last
53
+ # rendered row, and in raw mode \n alone is a bare line-feed (no
54
+ # column reset), which would indent whatever the host prints next.
55
+ @out.write("\r\n") # drop below the region, at column 0
52
56
  @out.write("\e[?25h") # restore cursor
53
57
  @out.flush
54
58
  end
@@ -97,15 +101,56 @@ module Potty
97
101
  @out.flush
98
102
  end
99
103
 
100
- # Inline mode ignores input; sleeping here gives the loop its tick
101
- # cadence. Terminal stays cooked, so Ctrl-C raises Interrupt normally.
104
+ # In listen mode: drain raw stdin (non-blocking, waiting up to one tick
105
+ # for the first byte), decode to Keys codes, and return them one per
106
+ # call (queueing the rest). Without listening (or off a real TTY): just
107
+ # pace the loop and return nil, leaving input alone. Ctrl-C arrives as a
108
+ # byte the decoder passes through as Keys::CTRL_C; the event loop raises.
102
109
  def read_key
103
- sleep(@tick_interval / 1000.0) if @tick_interval
104
- nil
110
+ return @queue.shift unless @queue.empty?
111
+
112
+ if @raw
113
+ fill_queue
114
+ @queue.shift
115
+ else
116
+ sleep(@tick_interval / 1000.0) if @tick_interval
117
+ nil
118
+ end
105
119
  end
106
120
 
107
121
  private
108
122
 
123
+ def enter_raw
124
+ return unless @input.respond_to?(:raw!) && tty_input?
125
+
126
+ @input.raw! # no echo, no canonical, no signal processing
127
+ @raw = true
128
+ end
129
+
130
+ def restore_cooked
131
+ return unless @raw
132
+
133
+ @input.cooked!
134
+ @raw = false
135
+ end
136
+
137
+ def tty_input?
138
+ @input.respond_to?(:tty?) && @input.tty?
139
+ end
140
+
141
+ def fill_queue
142
+ seconds = (@tick_interval || 40) / 1000.0
143
+ bytes = +''
144
+ if IO.select([@input], nil, nil, seconds)
145
+ begin
146
+ loop { bytes << @input.read_nonblock(256) }
147
+ rescue IO::WaitReadable, EOFError
148
+ # drained for now
149
+ end
150
+ end
151
+ @queue.concat(@decoder.feed(bytes, Time.now))
152
+ end
153
+
109
154
  def render_row(cells)
110
155
  last = cells.rindex { |ch, _| ch != ' ' } || -1
111
156
  return '' if last.negative?
@@ -115,27 +160,15 @@ module Potty
115
160
  (0..last).each do |i|
116
161
  ch, style = cells[i]
117
162
  if style != emitted
118
- out << sgr(style)
163
+ out << Ansi.sgr(style)
119
164
  emitted = style
120
165
  end
121
166
  out << ch
122
167
  end
123
- out << "\e[0m" if emitted
168
+ out << Ansi::RESET if emitted
124
169
  out
125
170
  end
126
171
 
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
172
  def detect_cols
140
173
  return @out.winsize[1] if @out.respond_to?(:winsize) && @out.tty?
141
174
 
data/lib/potty/theme.rb CHANGED
@@ -4,18 +4,18 @@ require 'curses'
4
4
  require_relative 'style'
5
5
 
6
6
  module Potty
7
- # Theme maps semantic names to colours, and speaks two dialects:
7
+ # Theme maps semantic names to a render-target-agnostic Style (symbolic
8
+ # colours + attributes). It is pure data — it does NOT touch curses. Each
9
+ # Surface resolves a Style to its concrete form (CursesSurface to a colour
10
+ # pair, InlineSurface to ANSI SGR), which is why a single Theme drives both
11
+ # rendering modes and why every widget that asks the theme for an attribute
12
+ # works in either mode with no per-widget special-casing.
8
13
  #
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.
14
+ # `style`, `[]`, and `attr` all return a Style `[]`/`attr` are kept as
15
+ # ergonomic aliases (attr adds bold/underline).
17
16
  class Theme
18
17
  # Symbolic colour names -> curses colour numbers (-1 = terminal default).
18
+ # Used by CursesSurface when it resolves a Style to a colour pair.
19
19
  COLORS = {
20
20
  default: -1,
21
21
  black: ::Curses::COLOR_BLACK, red: ::Curses::COLOR_RED,
@@ -41,24 +41,11 @@ module Potty
41
41
  status: { fg: :black, bg: :cyan }
42
42
  }.freeze
43
43
 
44
- attr_reader :palette, :colors
44
+ attr_reader :palette
45
45
 
46
46
  # Pass a partial palette ({ name => { fg:, bg: } }) to override entries.
47
47
  def initialize(palette = nil)
48
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
49
  end
63
50
 
64
51
  # Semantic style — symbolic colours + attributes, resolved by a Surface.
@@ -67,16 +54,14 @@ module Potty
67
54
  Style.new(fg: c[:fg], bg: c[:bg], bold: bold, underline: underline, reverse: reverse)
68
55
  end
69
56
 
70
- # Curses attribute integer (back-compat for direct-to-window drawing).
57
+ # Ergonomic aliases both return a Style, so widgets can use whichever
58
+ # reads best and still render in any mode.
71
59
  def [](name)
72
- @colors[name] || @colors[:normal] || 0
60
+ style(name)
73
61
  end
74
62
 
75
63
  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
64
+ style(name, bold: bold, underline: underline)
80
65
  end
81
66
  end
82
67
  end
data/lib/potty/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Potty
4
- VERSION = '0.0.1'
4
+ VERSION = '0.0.2'
5
5
  end
data/lib/potty/view.rb CHANGED
@@ -37,13 +37,20 @@ module Potty
37
37
  rows, cols = @app.surface.size
38
38
  container = Layout::Rect.new(0, 0, cols, rows)
39
39
 
40
- # Simple stack layout with spacing
41
- rects = Layout.stack(container, @widgets, spacing: 1)
40
+ # Simple stack layout. Override #spacing to pack tighter — inline views
41
+ # in particular want 0 so their height matches the region exactly.
42
+ rects = Layout.stack(container, @widgets, spacing: spacing)
42
43
  @widgets.zip(rects).each do |widget, rect|
43
44
  widget.layout(rect)
44
45
  end
45
46
  end
46
47
 
48
+ # Rows of blank space the default layout leaves between top-level widgets.
49
+ # Override to change it (e.g. 0 for a tightly-packed inline region).
50
+ def spacing
51
+ 1
52
+ end
53
+
47
54
  # Draw the widget tree onto the application's surface. The surface frame
48
55
  # (erase/present) is owned by Application#refresh_all, so this just paints.
49
56
  def render
@@ -63,9 +70,13 @@ module Potty
63
70
  # Delegate to focused widget first
64
71
  return true if focused_widget&.handle_key(ch)
65
72
 
66
- # Handle view-level keys
73
+ # Handle view-level keys. Enter advances focus like Tab when the
74
+ # focused widget didn't consume it — so a form flows field -> field ->
75
+ # button (where the button finally consumes Enter and fires). A view
76
+ # that wants Enter for itself (e.g. a prompt that submits) intercepts it
77
+ # before delegating to super.
67
78
  case ch
68
- when Keys::TAB
79
+ when Keys::TAB, *Keys::ENTERS
69
80
  cycle_focus(1)
70
81
  true
71
82
  when Keys::SHIFT_TAB
@@ -43,7 +43,7 @@ module Potty
43
43
  return unless @visible && @rect
44
44
 
45
45
  text = "[ #{@label} ]"[0, @rect.width]
46
- attr = @focused ? theme.attr(:selected, bold: true) : theme[@color]
46
+ attr = @focused ? theme.style(:selected, bold: true) : theme.style(@color)
47
47
  window.setpos(@rect.y, @rect.x)
48
48
  window.attron(attr) { window.addstr(text) }
49
49
  end
@@ -63,7 +63,7 @@ module Potty
63
63
 
64
64
  mark = selected?(opt[:value]) ? "[\u2713]" : "[ ]"
65
65
  on_cursor = @focused && i == @cursor
66
- attr = on_cursor ? theme.attr(:selected, bold: true) : theme[:normal]
66
+ attr = on_cursor ? theme.style(:selected, bold: true) : theme.style(:normal)
67
67
  window.setpos(@rect.y + i, @rect.x)
68
68
  window.attron(attr) { window.addstr("#{mark} #{opt[:label]}"[0, @rect.width]) }
69
69
  end
@@ -38,7 +38,7 @@ module Potty
38
38
  text = text[0, remaining]
39
39
 
40
40
  if color
41
- attr = bold ? theme.attr(color, bold: true) : theme[color]
41
+ attr = bold ? theme.style(color, bold: true) : theme.style(color)
42
42
  window.attron(attr) do
43
43
  window.addstr(text)
44
44
  end
@@ -74,7 +74,7 @@ module Potty
74
74
 
75
75
  text = @format.call(remaining).to_s[0, @rect.width]
76
76
  window.setpos(@rect.y, @rect.x)
77
- window.attron(theme[:warning]) { window.addstr(text) }
77
+ window.attron(theme.style(:warning)) { window.addstr(text) }
78
78
  end
79
79
  end
80
80
  end
@@ -45,10 +45,10 @@ module Potty
45
45
  # Show message if present
46
46
  if @message
47
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]
48
+ when :success then theme.style(:success)
49
+ when :error then theme.style(:error)
50
+ when :warning then theme.style(:warning)
51
+ else theme.style(:info)
52
52
  end
53
53
 
54
54
  window.setpos(@rect.y, @rect.x)
@@ -28,7 +28,7 @@ module Potty
28
28
  def render(window)
29
29
  return unless @visible && @rect
30
30
 
31
- attr = @bold ? theme.attr(@color, bold: true) : theme[@color]
31
+ attr = theme.style(@color, bold: @bold)
32
32
  window.setpos(@rect.y, @rect.x)
33
33
  window.attron(attr) { window.addstr(@text.to_s[0, @rect.width] || '') }
34
34
  end
@@ -55,7 +55,7 @@ module Potty
55
55
  # Show empty message if no items
56
56
  if @items.empty?
57
57
  window.setpos(@rect.y + 1, @rect.x + 2)
58
- window.attron(theme[:dim]) do
58
+ window.attron(theme.style(:dim)) do
59
59
  window.addstr("No items")
60
60
  end
61
61
  return
@@ -94,7 +94,7 @@ module Potty
94
94
  private
95
95
 
96
96
  def draw_border(window)
97
- Border.draw(window, @rect, attr: theme[:normal])
97
+ Border.draw(window, @rect, attr: theme.style(:normal))
98
98
  end
99
99
 
100
100
  def render_item(window, item, index, y, x)
@@ -105,7 +105,7 @@ module Potty
105
105
  window.setpos(y, x)
106
106
 
107
107
  # Selection prefix
108
- prefix_attr = is_selected && @focused ? theme.attr(:selected, bold: true) : theme[:normal]
108
+ prefix_attr = is_selected && @focused ? theme.style(:selected, bold: true) : theme.style(:normal)
109
109
  window.attron(prefix_attr) do
110
110
  prefix = is_selected ? "\u2192 " : " "
111
111
  window.addstr(prefix)
@@ -118,13 +118,13 @@ module Potty
118
118
 
119
119
  # Default single-color rendering
120
120
  attr = if is_selected && @focused
121
- theme.attr(:selected, bold: true)
121
+ theme.style(:selected, bold: true)
122
122
  elsif is_disabled
123
- theme[:dim]
123
+ theme.style(:dim)
124
124
  elsif item.color
125
- theme[item.color]
125
+ theme.style(item.color)
126
126
  else
127
- theme[:normal]
127
+ theme.style(:normal)
128
128
  end
129
129
 
130
130
  window.attron(attr) do
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'curses'
4
4
  require_relative '../keys'
5
+ require_relative '../line_editor'
5
6
 
6
7
  module Potty
7
8
  module Widgets
@@ -59,46 +60,44 @@ module Potty
59
60
  end
60
61
  end
61
62
 
62
- # Text input item - allows inline text editing
63
+ # Text input item inline text editing within a List. Shares the
64
+ # LineEditor model with the TextInput widget.
63
65
  class InputItem < ListItem
64
- attr_accessor :input_value
65
-
66
66
  def initialize(label, default: "", &on_submit)
67
67
  super(label)
68
- @input_value = default
68
+ @editor = LineEditor.new(default)
69
69
  @on_submit = on_submit
70
- @cursor_pos = @input_value.length
70
+ end
71
+
72
+ # Back-compat accessor for the entered text.
73
+ def input_value
74
+ @editor.text
75
+ end
76
+
77
+ def input_value=(value)
78
+ @editor.text = value
71
79
  end
72
80
 
73
81
  def display_text
74
- cursor = "_"
75
- "#{@text}: #{@input_value}#{cursor}"
82
+ "#{@text}: #{@editor.text}_"
76
83
  end
77
84
 
78
85
  def handle_key(ch)
79
86
  case ch
80
87
  when *Keys::ENTERS
81
- @on_submit&.call(@input_value)
82
- true
88
+ @on_submit&.call(@editor.text)
83
89
  when *Keys::BACKSPACES
84
- if @cursor_pos > 0
85
- @input_value[@cursor_pos - 1] = ''
86
- @cursor_pos -= 1
87
- end
88
- true
90
+ @editor.backspace
89
91
  when Keys::LEFT
90
- @cursor_pos = [@cursor_pos - 1, 0].max
91
- true
92
+ @editor.left
92
93
  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
94
+ @editor.right
95
+ when Keys::SPACE..(Keys::DEL_ASCII - 1) # Printable ASCII
96
+ @editor.insert(ch.chr)
99
97
  else
100
- false
98
+ return false
101
99
  end
100
+ true
102
101
  end
103
102
  end
104
103
 
@@ -41,7 +41,7 @@ module Potty
41
41
  def render(window)
42
42
  return unless @visible && @rect
43
43
 
44
- Border.draw(window, @rect, style: @style, attr: theme[@color], title: @title)
44
+ Border.draw(window, @rect, style: @style, attr: theme.style(@color), title: @title)
45
45
  super # render children inside the frame
46
46
  end
47
47
  end
@@ -41,6 +41,14 @@ module Potty
41
41
  @selected
42
42
  end
43
43
 
44
+ # The value under the cursor (the highlighted row), which may differ
45
+ # from the committed selection while navigating. Choosers commit this
46
+ # on Enter.
47
+ def cursor_value
48
+ opt = @options[@cursor]
49
+ opt && opt[:value]
50
+ end
51
+
44
52
  def selected=(value)
45
53
  idx = index_of(value)
46
54
  return unless idx
@@ -77,7 +85,7 @@ module Potty
77
85
 
78
86
  marker = opt[:value] == @selected ? "(\u25CF)" : "(\u25CB)"
79
87
  on_cursor = @focused && i == @cursor
80
- attr = on_cursor ? theme.attr(:selected, bold: true) : theme[:normal]
88
+ attr = on_cursor ? theme.style(:selected, bold: true) : theme.style(:normal)
81
89
  text = "#{marker} #{opt[:label]}"[0, @rect.width]
82
90
 
83
91
  window.setpos(@rect.y + i, @rect.x)
@@ -23,7 +23,7 @@ module Potty
23
23
  return unless @rect
24
24
 
25
25
  window.setpos(@rect.y, @rect.x)
26
- window.attron(theme[:status]) do
26
+ window.attron(theme.style(:status)) do
27
27
  # Clear line with background color
28
28
  window.addstr(" " * @rect.width)
29
29