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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 025204ee0b4853237fc9ea15a4714261d6583e827077cc38fd34dbe635f186c6
4
- data.tar.gz: ffc7a323d75589bbf073a70b136aec36f65a1a4ce4126d9c7a7986cabe484672
3
+ metadata.gz: 63152b715adc9214ddd12d6cf681554fbdc1351508ce17668f1c05b129778657
4
+ data.tar.gz: b2c1a6810bcb9549480761ac5384f8d1bf6eedf4074d71f2fd1a7883113f10d7
5
5
  SHA512:
6
- metadata.gz: b0534b15973e60c6fde7848fd7ff1d93db9e47f7f2b8bb3fa3b6e24cedefa36dfffb1c6c22b6d9fdc8e43a174a50be526c5b82cbb20793dfa9d772ff65d3bbda
7
- data.tar.gz: 1863a5f74f255b034dcb1f86dd23067c3823be15967f359249cc1d4f6c3b0ab54a959c7b18221c3a82c45f4cf573cf483ef63425cdef0196f734d0a2ce24da59
6
+ metadata.gz: c522818e767c48a23bac8d93619ea9f53db0f6e1987b8daa74059e1c53f0db848ee368f3c7701e6cc7236de6f5786f0fc7066a074e8dbdb66101c4511d6a914f
7
+ data.tar.gz: eb4a47e03d9a00ab977c3485853cf5d901fa2d4466c20e7be936811b63f59cbe2e42d66292c750f538fb24d9c5090ee7f6ce18a871cb4b8ec47ec9c2604db12b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to potty are documented here. The format is loosely based
4
4
  on [Keep a Changelog](https://keepachangelog.com/), and the project follows
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.0.2] - 2026-05-30
8
+
9
+ ### Added
10
+ - **Inline listen mode** — `InlineSurface` can now read input. With
11
+ `Application.new(mode: :inline, listen: true)` it puts stdin in raw mode,
12
+ decodes keys via `Potty::Input::Decoder`, and feeds the same event loop —
13
+ so existing widgets are interactive *inline*, no full-screen takeover.
14
+ Terminal restores to cooked on exit; Ctrl-C still quits.
15
+ - `Potty::Mouth` — batteries-included inline helpers built on
16
+ `Application.new(mode: :inline)` (a convenience layer, not an Application
17
+ facade):
18
+ - `Mouth.say(text, color)` — styled line with no app; drops colour when
19
+ output isn't a TTY so logs stay clean. (+ `Mouth.bleep`.)
20
+ - `Mouth.ask(prompt)` → String, `Mouth.confirm(prompt)` → bool,
21
+ `Mouth.choose(prompt, options)` → value — inline prompts (gum/fzf-style)
22
+ composed from the existing widgets, returning a value.
23
+ - `Potty::Input::Decoder` — raw byte stream → `Keys` codes (CSI/SS3 escape
24
+ sequences + bare-ESC timeout); the core that makes listen mode emit the
25
+ same codes curses does, so widgets work unchanged in either mode.
26
+ - `Potty::Ansi` — the symbolic-colour → SGR mapping, shared by `InlineSurface`
27
+ and `Mouth` (single source of truth).
28
+ - `RadioGroup#cursor_value` (the highlighted option, for one-shot choosers).
29
+ - `Application.new(out:, listen:, input:)` — redirect inline output, enable
30
+ listening, and inject the input IO (testability/piping).
31
+ - Second demo: `potty_inline_demo` (TTY/inline) alongside `potty_demo` (curses).
32
+
33
+ ### Changed
34
+ - **`Theme` is now pure data** (no curses) — `style`/`[]`/`attr` all return a
35
+ `Style` (symbolic colours + attributes), resolved per surface. This is what
36
+ lets every widget render in *either* mode (curses pair or ANSI SGR) with no
37
+ per-widget special-casing. Code that drew straight to a curses window with
38
+ `theme[:x]` must now draw via the surface (it resolves the Style).
39
+ - `View#spacing` is overridable; `Enter` advances focus like `Tab` when the
40
+ focused widget doesn't consume it (form flow).
41
+ - Internal consolidation: `TextInput` and the list `InputItem` now share a
42
+ `Potty::LineEditor`; dead `Layout.split_horizontal`/`fill` and the unused
43
+ `WindowManager` sub-window API removed.
44
+
45
+ ### CI
46
+ - GitHub Actions running the suite on Ruby 3.1–3.4.
47
+
7
48
  ## [0.0.1] - 2026-05-30
8
49
 
9
50
  Initial public release. (Developed privately under the working name `cursed`,
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # potty
2
2
 
3
+ [![CI](https://github.com/TwilightCoders/potty/actions/workflows/ci.yml/badge.svg)](https://github.com/TwilightCoders/potty/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/potty.svg)](https://rubygems.org/gems/potty)
5
+
3
6
  A curses-based terminal UI framework for Ruby. Build full-screen TUIs from a
4
7
  tree of composable widgets, with view-stack navigation, a focus model, theming,
5
8
  and frame-based animation.
@@ -70,19 +73,23 @@ app = Potty::Application.new
70
73
  app.run(HelloView.new(app))
71
74
  ```
72
75
 
73
- For a guided tour of the whole widget set, run the bundled demo in a real
74
- terminal:
76
+ Two bundled demos, one per rendering mode run either in a real terminal:
75
77
 
76
78
  ```bash
77
- bin/potty_demo # from a checkout
78
- potty_demo # when the gem is installed
79
+ bin/potty_demo # curses: full-screen
80
+ bin/potty_inline_demo # inline: stays in the terminal flow
79
81
  ```
80
82
 
81
- It's a single self-demonstrating dashboard: one composed layout (so it shows
82
- off the layout system by *being* it) whose form controls reconfigure the demo
83
- live — the Border radio restyles the very panels you're looking at, the Title
84
- field renames the header, the checkboxes show/hide the live animation. See
85
- [`examples/test_view.rb`](examples/test_view.rb) for a smaller example.
83
+ - **`potty_demo`** (curses) — a single self-demonstrating dashboard: one
84
+ composed layout (it shows off the layout system by *being* it) whose form
85
+ controls reconfigure the demo live — the Border radio restyles the very
86
+ panels you're looking at, the Title field renames the header, the checkboxes
87
+ show/hide the live animation.
88
+ - **`potty_inline_demo`** (inline/TTY) — styled output, a live in-place
89
+ "deploy" (spinners resolving without a screen takeover), and interactive
90
+ prompts (`ask`/`confirm`/`choose`) — all in your normal terminal flow.
91
+
92
+ See [`examples/test_view.rb`](examples/test_view.rb) for a smaller example.
86
93
 
87
94
  ## Core concepts
88
95
 
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # potty_inline_demo - the TTY / inline half of potty: rendering and
5
+ # interaction that stay in your terminal's normal flow (no full-screen
6
+ # takeover). Run it in a real terminal:
7
+ #
8
+ # bin/potty_inline_demo (from a checkout)
9
+ # potty_inline_demo (when the gem is installed)
10
+ #
11
+ # The curses (full-screen) counterpart is `potty_demo`.
12
+
13
+ lib = File.expand_path('../lib', __dir__)
14
+ $LOAD_PATH.unshift(lib) if File.directory?(lib) && !$LOAD_PATH.include?(lib)
15
+
16
+ require 'potty'
17
+
18
+ module PottyInlineDemo
19
+ # A short live "deploy": two spinners that resolve, redrawn in place under
20
+ # the cursor (no screen takeover). Quits itself when finished.
21
+ class DeployView < Potty::View
22
+ def build_layout
23
+ @label = Potty::Widgets::Label.new(app, text: 'Deploying (inline, in place):', color: :info)
24
+ @build = Potty::Widgets::Spinner.new(app, label: 'building assets')
25
+ @upload = Potty::Widgets::Spinner.new(app, label: 'uploading bundle')
26
+ @widgets = [@label, @build, @upload]
27
+ end
28
+
29
+ def spacing
30
+ 0 # pack tight so all three lines fit the sized inline region
31
+ end
32
+
33
+ def tick(now)
34
+ @start ||= now
35
+ super
36
+ elapsed = now - @start
37
+ @build.complete!(:success) if elapsed > 1.0 && @build.active?
38
+ @upload.complete!(:success) if elapsed > 2.0 && @upload.active?
39
+ app.quit if elapsed > 2.6
40
+ end
41
+ end
42
+
43
+ module_function
44
+
45
+ def run
46
+ Potty::Mouth.say('potty - inline demo (Pretty Objects: TTY)', :info, bold: true)
47
+ Potty::Mouth.say('Everything below stays in your terminal - no screen takeover.', :dim)
48
+ puts
49
+
50
+ # Passive live region (output only): spinners resolving in place.
51
+ app = Potty::Application.new(mode: :inline, lines: 3)
52
+ app.tick_interval = 40
53
+ app.run(DeployView.new(app))
54
+ puts
55
+
56
+ # Interactive prompts (listen mode): real input, rendered in place.
57
+ name = Potty::Mouth.ask("What should I call you?", default: 'stranger')
58
+ Potty::Mouth.say("Hi, #{name}!", :success)
59
+
60
+ theme = Potty::Mouth.choose('Pick a theme:', %i[light dark auto])
61
+ Potty::Mouth.say("Theme: #{theme}", :info)
62
+
63
+ if Potty::Mouth.confirm('Curse a little?', default: true)
64
+ Potty::Mouth.say("#{Potty::Mouth.bleep('damn')} right.", :warning)
65
+ end
66
+
67
+ Potty::Mouth.say('Inline demo done.', :success)
68
+ end
69
+ end
70
+
71
+ if __FILE__ == $PROGRAM_NAME
72
+ begin
73
+ PottyInlineDemo.run
74
+ rescue Interrupt
75
+ warn "\nInterrupted."
76
+ exit 130
77
+ end
78
+ end
data/lib/potty/ansi.rb ADDED
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Potty
4
+ # Resolve a Style to ANSI SGR escape codes. Shared by InlineSurface (which
5
+ # paints a region) and Potty::Mouth (one-off styled lines), so the
6
+ # symbolic-colour -> SGR mapping lives in exactly one place.
7
+ module Ansi
8
+ SGR_FG = {
9
+ default: 39, black: 30, red: 31, green: 32, yellow: 33,
10
+ blue: 34, magenta: 35, cyan: 36, white: 37, bright_black: 90
11
+ }.freeze
12
+ SGR_BG = {
13
+ default: 49, black: 40, red: 41, green: 42, yellow: 43,
14
+ blue: 44, magenta: 45, cyan: 46, white: 47, bright_black: 100
15
+ }.freeze
16
+ RESET = "\e[0m"
17
+
18
+ module_function
19
+
20
+ def sgr(style)
21
+ return RESET if style.nil?
22
+
23
+ codes = []
24
+ codes << 1 if style.bold?
25
+ codes << 4 if style.underline?
26
+ codes << 7 if style.reverse?
27
+ codes << SGR_FG.fetch(style.fg, 39)
28
+ codes << SGR_BG.fetch(style.bg, 49)
29
+ "\e[#{codes.join(';')}m"
30
+ end
31
+
32
+ def reset
33
+ RESET
34
+ end
35
+ end
36
+ end
@@ -25,12 +25,15 @@ module Potty
25
25
  # ~33-50ms gives smooth animation. Required for :inline.
26
26
  attr_accessor :tick_interval
27
27
 
28
- def initialize(mode: :curses, lines: nil, theme: nil)
28
+ def initialize(mode: :curses, lines: nil, theme: nil, out: $stdout, listen: false, input: $stdin)
29
29
  @view_stack = []
30
30
  @running = false
31
31
  @theme = theme || Theme.new
32
32
  @mode = mode
33
33
  @lines = lines
34
+ @out = out
35
+ @listen = listen
36
+ @input = input
34
37
  # Kept for back-compat: curses-mode consumers that draw straight to
35
38
  # window_manager.stdscr or read its dimensions.
36
39
  @window_manager = (mode == :curses ? WindowManager.new : nil)
@@ -102,7 +105,8 @@ module Potty
102
105
  def build_surface
103
106
  case @mode
104
107
  when :inline
105
- Surfaces::InlineSurface.new(theme: @theme, lines: @lines, tick_interval: @tick_interval || 40)
108
+ Surfaces::InlineSurface.new(theme: @theme, lines: @lines, tick_interval: @tick_interval || 40,
109
+ out: @out, listen: @listen, input: @input)
106
110
  else
107
111
  Surfaces::CursesSurface.new(@window_manager, @theme, tick_interval: @tick_interval)
108
112
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../keys'
4
+
5
+ module Potty
6
+ module Input
7
+ # Turns a raw terminal byte stream into Keys codes. In curses mode the
8
+ # library gets this for free via keypad(true); inline "listen" mode reads
9
+ # raw bytes, so we decode here — and crucially we emit the *same* integer
10
+ # codes curses would, so every widget's handle_key works unchanged in
11
+ # either mode.
12
+ #
13
+ # Printable/control bytes pass straight through as codes. Escape sequences
14
+ # (ESC [ A, ESC O P, ESC [ 3 ~, …) map to Keys::UP / DELETE / etc. A lone
15
+ # ESC is ambiguous — it may begin a sequence — so it's held until either
16
+ # more bytes complete a sequence, or enough time passes (escape_timeout)
17
+ # that we resolve it to a bare ESC. Same heuristic curses runs internally
18
+ # via ESCDELAY; here it's ours.
19
+ #
20
+ # Usage (driven by the inline loop each tick):
21
+ # keys = decoder.feed(bytes_available, now) # bytes may be ""
22
+ # keys.each { |code| view.handle_key(code) }
23
+ class Decoder
24
+ # Escape sequence (the bytes after ESC) -> Keys code.
25
+ SEQUENCES = {
26
+ '[A' => Keys::UP, 'OA' => Keys::UP,
27
+ '[B' => Keys::DOWN, 'OB' => Keys::DOWN,
28
+ '[C' => Keys::RIGHT, 'OC' => Keys::RIGHT,
29
+ '[D' => Keys::LEFT, 'OD' => Keys::LEFT,
30
+ '[H' => Keys::HOME, 'OH' => Keys::HOME, '[1~' => Keys::HOME,
31
+ '[F' => Keys::END_, 'OF' => Keys::END_, '[4~' => Keys::END_,
32
+ '[3~' => Keys::DELETE,
33
+ '[Z' => Keys::SHIFT_TAB
34
+ }.freeze
35
+
36
+ # Longest escape body we might still be completing (e.g. "[3~").
37
+ MAX_SEQ = SEQUENCES.keys.map(&:length).max
38
+
39
+ def initialize(escape_timeout: 0.25)
40
+ @escape_timeout = escape_timeout
41
+ @buffer = +''
42
+ @esc_at = nil
43
+ end
44
+
45
+ # Append newly-read bytes (may be empty) and return the key codes that
46
+ # can be resolved now. `now` is a monotonic-ish time used only for the
47
+ # bare-ESC timeout; pass Time.now from the loop (and in tests).
48
+ def feed(bytes, now)
49
+ @buffer << bytes.to_s
50
+ codes = []
51
+ while (code = take(now))
52
+ codes << code
53
+ end
54
+ codes
55
+ end
56
+
57
+ private
58
+
59
+ # Pull the next resolvable key from the buffer, or nil if we must wait.
60
+ def take(now)
61
+ return nil if @buffer.empty?
62
+
63
+ if @buffer.getbyte(0) == Keys::ESC
64
+ take_escape(now)
65
+ else
66
+ @esc_at = nil
67
+ byte = @buffer.getbyte(0)
68
+ @buffer.slice!(0)
69
+ byte
70
+ end
71
+ end
72
+
73
+ def take_escape(now)
74
+ body = @buffer[1..] || ''
75
+
76
+ # A complete, recognized sequence?
77
+ if (seq = SEQUENCES.keys.find { |s| body.start_with?(s) })
78
+ @buffer.slice!(0, 1 + seq.length)
79
+ @esc_at = nil
80
+ return SEQUENCES[seq]
81
+ end
82
+
83
+ # Still possibly the beginning of one? Hold and wait for more bytes,
84
+ # unless we've waited past the escape timeout — then it's a bare ESC.
85
+ if could_complete?(body)
86
+ @esc_at ||= now
87
+ return nil unless timed_out?(now)
88
+ end
89
+
90
+ # Bare ESC (lone, timed out, or ESC followed by an unrelated byte).
91
+ @buffer.slice!(0)
92
+ @esc_at = nil
93
+ Keys::ESC
94
+ end
95
+
96
+ # Could `body` (bytes after ESC) still grow into a known sequence?
97
+ def could_complete?(body)
98
+ return true if body.empty? && incomplete_possible?
99
+ return false if body.length >= MAX_SEQ
100
+
101
+ SEQUENCES.keys.any? { |s| s.start_with?(body) && s != body }
102
+ end
103
+
104
+ def incomplete_possible?
105
+ true # a lone ESC could begin any sequence
106
+ end
107
+
108
+ def timed_out?(now)
109
+ @esc_at && (now - @esc_at) >= @escape_timeout
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/potty/layout.rb CHANGED
@@ -20,28 +20,5 @@ module Potty
20
20
  rect
21
21
  end
22
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
23
  end
47
24
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Potty
4
+ # The single-line text-editing model shared by the TextInput widget and the
5
+ # list InputItem — a string plus a caret, with insert / delete / navigation.
6
+ # Pure logic, no rendering or curses: widgets own how it's drawn and when to
7
+ # fire change events (the mutators return whether the text changed).
8
+ class LineEditor
9
+ attr_reader :text, :cursor
10
+ attr_accessor :max_length
11
+
12
+ def initialize(text = '', max_length: nil)
13
+ @text = text.to_s.dup
14
+ @max_length = max_length
15
+ @cursor = @text.length
16
+ end
17
+
18
+ def text=(value)
19
+ @text = value.to_s.dup
20
+ @cursor = [@cursor, @text.length].min
21
+ end
22
+
23
+ # Mutators return true when the text changed (so callers know to notify).
24
+ def insert(str)
25
+ return false if @max_length && @text.length >= @max_length
26
+
27
+ @text.insert(@cursor, str)
28
+ @cursor += str.length
29
+ true
30
+ end
31
+
32
+ def backspace
33
+ return false if @cursor.zero?
34
+
35
+ @text.slice!(@cursor - 1)
36
+ @cursor -= 1
37
+ true
38
+ end
39
+
40
+ def delete_forward
41
+ return false if @cursor >= @text.length
42
+
43
+ @text.slice!(@cursor)
44
+ true
45
+ end
46
+
47
+ # Caret navigation (no text change).
48
+ def left = (@cursor = [@cursor - 1, 0].max)
49
+ def right = (@cursor = [@cursor + 1, @text.length].min)
50
+ def home = (@cursor = 0)
51
+ def to_end = (@cursor = @text.length)
52
+ end
53
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'style'
4
+ require_relative 'theme'
5
+ require_relative 'ansi'
6
+ require_relative 'application'
7
+ require_relative 'view'
8
+ require_relative 'widgets/label'
9
+ require_relative 'widgets/text_input'
10
+ require_relative 'widgets/radio_group'
11
+
12
+ module Potty
13
+ # potty's batteries-included inline helpers — quick terminal I/O that hides
14
+ # the Application/View machinery and hands you back a value:
15
+ #
16
+ # Potty::Mouth.say("deploying…", :info) # styled output, no app
17
+ # name = Potty::Mouth.ask("Your name?") # (with listen mode) -> String
18
+ # ok = Potty::Mouth.confirm("Proceed?") # -> true/false
19
+ #
20
+ # This is a convenience layer *built on* Application.new(mode: :inline), not
21
+ # a second way to run views — to run your own inline View, use the inline
22
+ # Application directly. Colour is dropped when output isn't a TTY, so logs
23
+ # stay clean.
24
+ module Mouth
25
+ module_function
26
+
27
+ # Print one styled line. `color` is a Theme palette name (:info, :success,
28
+ # :error, :warning, :dim, …); unknown names fall back to :normal.
29
+ def say(text, color = :normal, bold: false, out: $stdout)
30
+ if tty?(out)
31
+ out.puts("#{Ansi.sgr(style_for(color, bold))}#{text}#{Ansi::RESET}")
32
+ else
33
+ out.puts(text)
34
+ end
35
+ nil
36
+ end
37
+
38
+ # Censor a word — a potty mouth ought to know how. Keeps the first and
39
+ # last letter, stars the middle. (Purely for the laugh.)
40
+ def bleep(word, char: '*')
41
+ return word if word.length <= 2
42
+
43
+ "#{word[0]}#{char * (word.length - 2)}#{word[-1]}"
44
+ end
45
+
46
+ # Ask for a line of text inline; returns the entered String, or nil if the
47
+ # user cancels with ESC. Needs a TTY — off a TTY (pipe/cron) it can't
48
+ # prompt, so it returns `default` rather than hanging.
49
+ def ask(prompt, default: '', theme: nil, out: $stdout, input: $stdin)
50
+ return default unless tty?(input)
51
+
52
+ app = inline_app(lines: 2, theme: theme, out: out, input: input)
53
+ app.run(view = Prompt::Ask.new(app, prompt: prompt, default: default))
54
+ view.result
55
+ end
56
+
57
+ # Yes/no inline; returns true/false (ESC or Enter use `default`). Off a
58
+ # TTY, returns `default`.
59
+ def confirm(prompt, default: false, theme: nil, out: $stdout, input: $stdin)
60
+ return default unless tty?(input)
61
+
62
+ app = inline_app(lines: 1, theme: theme, out: out, input: input)
63
+ app.run(view = Prompt::Confirm.new(app, prompt: prompt, default: default))
64
+ view.result
65
+ end
66
+
67
+ # Pick one of `options` (values or {value:, label:}) inline; returns the
68
+ # chosen value, or nil on ESC. Arrows move, Enter picks. Off a TTY,
69
+ # returns `default` (the first option's value unless given).
70
+ def choose(prompt, options, default: :__first__, theme: nil, out: $stdout, input: $stdin)
71
+ unless tty?(input)
72
+ return default == :__first__ ? value_of(options.first) : default
73
+ end
74
+
75
+ app = inline_app(lines: options.size + 1, theme: theme, out: out, input: input)
76
+ app.run(view = Prompt::Choose.new(app, prompt: prompt, options: options))
77
+ view.result
78
+ end
79
+
80
+ # Build (not run) a configured inline, listening Application for a prompt.
81
+ def inline_app(lines:, theme:, out:, input:)
82
+ app = Application.new(mode: :inline, listen: true, lines: lines, theme: theme, out: out, input: input)
83
+ app.tick_interval = 50
84
+ app
85
+ end
86
+
87
+ def style_for(color, bold)
88
+ c = Theme::PALETTE[color] || Theme::PALETTE[:normal]
89
+ Style.new(fg: c[:fg], bg: c[:bg], bold: bold)
90
+ end
91
+
92
+ def tty?(io)
93
+ io.respond_to?(:tty?) && io.tty?
94
+ end
95
+
96
+ def value_of(option)
97
+ option.is_a?(Hash) ? option[:value] : option
98
+ end
99
+
100
+ # Small inline prompt views backing ask/confirm/choose. Each captures its
101
+ # outcome in #result and quits the app when answered.
102
+ module Prompt
103
+ # Prompts pack tight (no inter-widget spacing) so the view height matches
104
+ # the inline region exactly — otherwise the default spacing pushes fields
105
+ # past the sized region and they render clipped/invisible.
106
+ class Base < Potty::View
107
+ def spacing
108
+ 0
109
+ end
110
+ end
111
+
112
+ class Ask < Base
113
+ attr_reader :result
114
+
115
+ def initialize(app, prompt:, default: '')
116
+ @prompt = prompt
117
+ @default = default
118
+ @result = nil
119
+ super(app)
120
+ end
121
+
122
+ def build_layout
123
+ @field = Potty::Widgets::TextInput.new(app, text: @default)
124
+ @widgets = [Potty::Widgets::Label.new(app, text: @prompt, color: :info), @field]
125
+ @field.focus
126
+ end
127
+
128
+ def handle_key(ch)
129
+ # Intercept Enter before super (which would otherwise advance focus)
130
+ # — a single-field prompt submits on Enter.
131
+ if Potty::Keys.enter?(ch)
132
+ @result = @field.text
133
+ app.quit
134
+ return true
135
+ end
136
+ super
137
+ end
138
+
139
+ def handle_escape
140
+ @result = nil
141
+ app.quit
142
+ true
143
+ end
144
+ end
145
+
146
+ class Confirm < Base
147
+ attr_reader :result
148
+
149
+ def initialize(app, prompt:, default: false)
150
+ @prompt = prompt
151
+ @default = default
152
+ @result = nil
153
+ super(app)
154
+ end
155
+
156
+ def build_layout
157
+ hint = @default ? '[Y/n]' : '[y/N]'
158
+ @widgets = [Potty::Widgets::Label.new(app, text: "#{@prompt} #{hint}", color: :info)]
159
+ end
160
+
161
+ def handle_key(ch)
162
+ case ch
163
+ when 'y'.ord, 'Y'.ord then finish(true)
164
+ when 'n'.ord, 'N'.ord then finish(false)
165
+ when *Potty::Keys::ENTERS then finish(@default)
166
+ else false
167
+ end
168
+ end
169
+
170
+ def handle_escape
171
+ finish(@default)
172
+ end
173
+
174
+ private
175
+
176
+ def finish(value)
177
+ @result = value
178
+ app.quit
179
+ true
180
+ end
181
+ end
182
+
183
+ class Choose < Base
184
+ attr_reader :result
185
+
186
+ def initialize(app, prompt:, options:)
187
+ @prompt = prompt
188
+ @options = options
189
+ @result = nil
190
+ super(app)
191
+ end
192
+
193
+ def build_layout
194
+ opts = @options.map { |o| o.is_a?(Hash) ? o : { value: o, label: o.to_s } }
195
+ @radio = Potty::Widgets::RadioGroup.new(app, options: opts)
196
+ @widgets = [Potty::Widgets::Label.new(app, text: @prompt, color: :info), @radio]
197
+ @radio.focus
198
+ end
199
+
200
+ def handle_key(ch)
201
+ if Potty::Keys.enter?(ch)
202
+ @result = @radio.cursor_value
203
+ app.quit
204
+ return true
205
+ end
206
+ super # arrows move the radio cursor
207
+ end
208
+
209
+ def handle_escape
210
+ @result = nil
211
+ app.quit
212
+ true
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end