clack 0.6.1 → 0.7.0

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.
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Clack
4
4
  module Core
5
- # Global configuration for key bindings, guide bar display, and input classification.
5
+ # Global configuration for key bindings, guide bar display, keyboard hint
6
+ # display, input classification, and the user-facing cancel/error strings.
6
7
  module Settings
7
8
  # Navigation and control actions
8
9
  ACTIONS = %i[up down left right space enter cancel].freeze
@@ -16,6 +17,8 @@ module Clack
16
17
  KEY_ENTER = "\r" # ASCII 13: Carriage return
17
18
  KEY_NEWLINE = "\n" # ASCII 10: Line feed
18
19
  KEY_SPACE = " " # ASCII 32: Space
20
+ KEY_HOME = "\e[H" # Canonical Home (CSI H)
21
+ KEY_END = "\e[F" # Canonical End (CSI F)
19
22
 
20
23
  # First printable ASCII character (space)
21
24
  PRINTABLE_CHAR_MIN = 32
@@ -37,11 +40,35 @@ module Clack
37
40
  KEY_CTRL_C => :cancel
38
41
  }.freeze
39
42
 
43
+ # Terminals encode the same key in several ways: SS3 (+ESC O x+) in
44
+ # application cursor mode, and Home/End as +ESC [ H+, +ESC [ 1 ~+ or
45
+ # +ESC [ 7 ~+ depending on the terminal. {KeyReader} folds every variant
46
+ # into the canonical code on the right, so prompts and aliases only ever
47
+ # deal with one code per key.
48
+ KEY_NORMALIZE = {
49
+ "\eOA" => "\e[A",
50
+ "\eOB" => "\e[B",
51
+ "\eOC" => "\e[C",
52
+ "\eOD" => "\e[D",
53
+ "\eOH" => KEY_HOME,
54
+ "\e[1~" => KEY_HOME,
55
+ "\e[7~" => KEY_HOME,
56
+ "\eOF" => KEY_END,
57
+ "\e[4~" => KEY_END,
58
+ "\e[8~" => KEY_END
59
+ }.freeze
60
+
61
+ # Default user-facing strings, overridable via update(messages:) for localization.
62
+ DEFAULT_MESSAGES = {cancel: "Cancelled", error: "Something went wrong"}.freeze
63
+ MESSAGE_KEYS = DEFAULT_MESSAGES.keys.freeze
64
+
40
65
  # Global configuration (mutable)
41
66
  @config = {
42
67
  aliases: ALIASES.dup,
43
68
  with_guide: true,
44
- ci_mode: false
69
+ show_instructions: true,
70
+ ci_mode: false,
71
+ messages: DEFAULT_MESSAGES
45
72
  }
46
73
  @config_mutex = Mutex.new
47
74
 
@@ -55,34 +82,67 @@ module Clack
55
82
  # Update global settings
56
83
  # @param aliases [Hash, nil] Custom key to action mappings (merged with defaults)
57
84
  # @param with_guide [Boolean, nil] Whether to show guide bars
85
+ # @param show_instructions [Boolean, nil] Whether prompts show their keyboard hint footer
58
86
  # @param ci_mode [Boolean, Symbol, nil] CI mode: true (always), :auto (detect), false (never)
87
+ # @param messages [Hash{Symbol=>String}, nil] Cancel/error strings, merged with the
88
+ # current values: +{cancel: "Cancelled", error: "Something went wrong"}+
59
89
  # @return [Hash] Updated configuration
60
- def update(aliases: nil, with_guide: nil, ci_mode: nil)
90
+ # @raise [ArgumentError] if messages is not a Hash, has an unknown key, or a non-String value
91
+ def update(aliases: nil, with_guide: nil, show_instructions: nil, ci_mode: nil, messages: nil)
92
+ validate_messages(messages) if messages
61
93
  @config_mutex.synchronize do
62
94
  @config[:aliases] = ALIASES.merge(aliases) if aliases
63
95
  @config[:with_guide] = with_guide unless with_guide.nil?
96
+ @config[:show_instructions] = show_instructions unless show_instructions.nil?
64
97
  @config[:ci_mode] = ci_mode unless ci_mode.nil?
98
+ # Values are frozen too (-value dedups without to_s), so a caller's
99
+ # mutable String cannot change global state after the fact.
100
+ @config[:messages] = @config[:messages].merge(messages.transform_values { |value| -value }).freeze if messages
65
101
  @config.dup
66
102
  end
67
103
  end
68
104
 
105
+ # Current cancel/error strings.
106
+ # @return [Hash{Symbol=>String}] frozen
107
+ def messages = @config_mutex.synchronize { @config[:messages] }
108
+
109
+ # Look up one user-facing string.
110
+ # @param key [Symbol] :cancel or :error
111
+ # @return [String]
112
+ # @raise [KeyError] for an unknown key
113
+ def message(key) = messages.fetch(key)
114
+
69
115
  # Reset settings to defaults
70
116
  def reset!
71
117
  @config_mutex.synchronize do
72
118
  @config = {
73
119
  aliases: ALIASES.dup,
74
120
  with_guide: true,
75
- ci_mode: false
121
+ show_instructions: true,
122
+ ci_mode: false,
123
+ messages: DEFAULT_MESSAGES
76
124
  }
77
125
  end
78
126
  end
79
127
 
80
- # Check if guide bars should be shown
128
+ # Resolve whether guide bars are shown.
129
+ # @param override [Boolean, nil] per-call value; nil means "use the global setting"
81
130
  # @return [Boolean]
82
- def with_guide?
131
+ def with_guide?(override = nil)
132
+ return override unless override.nil?
133
+
83
134
  @config_mutex.synchronize { @config[:with_guide] }
84
135
  end
85
136
 
137
+ # Resolve whether keyboard hint footers are shown.
138
+ # @param override [Boolean, nil] per-call value; nil means "use the global setting"
139
+ # @return [Boolean]
140
+ def show_instructions?(override = nil)
141
+ return override unless override.nil?
142
+
143
+ @config_mutex.synchronize { @config[:show_instructions] }
144
+ end
145
+
86
146
  # Look up the action mapped to a key code.
87
147
  # @param key [String] key code from {KeyReader}
88
148
  # @return [Symbol, nil] the action (:up, :down, :enter, etc.) or nil
@@ -91,6 +151,13 @@ module Clack
91
151
  aliases[key] if ACTIONS.include?(aliases[key])
92
152
  end
93
153
 
154
+ # Fold terminal-specific escape sequence variants into one canonical code.
155
+ # @param key [String] key code as assembled by {KeyReader}
156
+ # @return [String] the canonical code (unchanged when no variant matches)
157
+ def normalize_key(key)
158
+ KEY_NORMALIZE.fetch(key, key)
159
+ end
160
+
94
161
  # Check if a key is a printable character (handles combining marks and multi-codepoint grapheme clusters)
95
162
  def printable?(key)
96
163
  key && key.grapheme_clusters.length == 1 && key.ord >= PRINTABLE_CHAR_MIN
@@ -100,6 +167,20 @@ module Clack
100
167
  def backspace?(key)
101
168
  [KEY_BACKSPACE, KEY_DELETE].include?(key)
102
169
  end
170
+
171
+ private
172
+
173
+ # Raises before any state changes so a bad call leaves the config untouched.
174
+ def validate_messages(messages)
175
+ raise ArgumentError, "messages must be a Hash" unless messages.is_a?(Hash)
176
+
177
+ messages.each do |key, value|
178
+ unless MESSAGE_KEYS.include?(key)
179
+ raise ArgumentError, "unknown messages key: #{key.inspect} (expected #{MESSAGE_KEYS.map(&:inspect).join(", ")})"
180
+ end
181
+ raise ArgumentError, "messages[#{key.inspect}] must be a String" unless value.is_a?(String)
182
+ end
183
+ end
103
184
  end
104
185
  end
105
186
  end
@@ -57,12 +57,36 @@ module Clack
57
57
  ENV["TERM"] == "dumb"
58
58
  end
59
59
 
60
- # Check if ANSI colors are supported
61
- # @param output [IO] Output stream to check
60
+ # Check if ANSI colors are supported on +output+.
61
+ #
62
+ # Resolution order, first match wins:
63
+ # 1. +FORCE_COLOR=0+ or +FORCE_COLOR=false+ (any case): +false+
64
+ # 2. +NO_COLOR+ set to a non-empty value: +false+ (see https://no-color.org)
65
+ # 3. +FORCE_COLOR+ set to any other non-empty value: +true+, even when
66
+ # +output+ is not a TTY or +TERM=dumb+
67
+ # 4. +output+ is not a TTY: +false+
68
+ # 5. +TERM=dumb+: +false+
69
+ # 6. Otherwise +true+
70
+ #
71
+ # An empty +NO_COLOR+ or +FORCE_COLOR+ is treated as unset. The result is
72
+ # not cached; every call re-reads the environment.
73
+ #
74
+ # {Colors.enabled?}, {Core::Cursor.enabled?} and {Symbols.unicode?} all
75
+ # delegate here, so these variables also control cursor escape sequences
76
+ # and the Unicode/ASCII symbol set (unless +CLACK_UNICODE+ overrides the
77
+ # latter).
78
+ #
79
+ # @param output [IO] Output stream to check (default: $stdout)
62
80
  # @return [Boolean]
63
81
  def colors_supported?(output = $stdout)
64
- return false if ENV["NO_COLOR"]
65
- return true if ENV["FORCE_COLOR"]
82
+ force = ENV["FORCE_COLOR"].to_s
83
+ # The binary copy matters: ENV values carry the locale encoding, and
84
+ # String#casecmp? raises ArgumentError on invalid bytes in that
85
+ # encoding. Symbols calls this at require time, so a stray
86
+ # FORCE_COLOR=$'\xff' would otherwise make `require "clack"` raise.
87
+ return false if force == "0" || force.b.casecmp?("false")
88
+ return false unless ENV["NO_COLOR"].to_s.empty?
89
+ return true unless force.empty?
66
90
  return false unless tty?(output)
67
91
  return false if dumb_terminal?
68
92
 
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ # Raised when a prompt needs keystrokes but its input stream is not an
5
+ # interactive terminal (for example, stdin is a pipe, a file, or /dev/null).
6
+ #
7
+ # Inherits from IOError so existing +rescue IOError+ guards keep working.
8
+ # The message names the fix: run from a terminal, or enable CI mode so
9
+ # prompts auto-submit their defaults.
10
+ #
11
+ # @example
12
+ # begin
13
+ # Clack.text(message: "Name?")
14
+ # rescue Clack::NotATerminalError => e
15
+ # warn e.message
16
+ # exit 1
17
+ # end
18
+ class NotATerminalError < IOError; end
19
+ end
data/lib/clack/log.rb CHANGED
@@ -6,6 +6,11 @@ module Clack
6
6
  # Each method prints a message prefixed with a colored symbol. Multi-line
7
7
  # messages are automatically aligned with a continuation bar on subsequent lines.
8
8
  #
9
+ # With guides off (+with_guide: false+ or +Clack.update_settings(with_guide: false)+)
10
+ # the level symbol stays on the first line and only the continuation rail goes
11
+ # away, so +log.error+ still looks different from +log.info+ (upstream drops the
12
+ # symbol as well).
13
+ #
9
14
  # Accessed via +Clack.log+:
10
15
  #
11
16
  # @example
@@ -23,22 +28,26 @@ module Clack
23
28
  # log levels).
24
29
  #
25
30
  # @param msg [String] the message to display
26
- # @param symbol [String, nil] custom prefix symbol (default: gray bar)
31
+ # @param symbol [String, nil] custom prefix symbol (default: gray bar, or nothing with guides off)
32
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
27
33
  # @param output [IO] output stream (default: $stdout)
28
34
  # @return [void]
29
35
  #
30
36
  # @example Custom symbol
31
- # Clack.log.message("Deploying...", symbol: "\u2708")
32
- def message(msg = "", symbol: nil, output: $stdout)
33
- symbol ||= Colors.gray(Symbols::S_BAR)
37
+ # Clack.log.message("Deploying...", symbol: "")
38
+ def message(msg = "", symbol: nil, with_guide: nil, output: $stdout)
39
+ guide = Core::Settings.with_guide?(with_guide)
40
+ rail = Colors.gray(Symbols::S_BAR)
41
+ first = symbol || (guide ? rail : nil)
42
+ first_prefix = first ? "#{first} " : ""
43
+ next_prefix = guide ? "#{rail} " : ""
34
44
  lines = msg.to_s.lines
35
45
 
36
46
  if lines.empty?
37
- output.puts symbol
47
+ output.puts first.to_s
38
48
  else
39
49
  lines.each_with_index do |line, idx|
40
- prefix = idx.zero? ? symbol : Colors.gray(Symbols::S_BAR)
41
- output.puts "#{prefix} #{line.chomp}"
50
+ output.puts "#{idx.zero? ? first_prefix : next_prefix}#{line.chomp}"
42
51
  end
43
52
  end
44
53
  end
@@ -46,47 +55,52 @@ module Clack
46
55
  # Print an informational message (blue symbol).
47
56
  #
48
57
  # @param msg [String] the message to display
58
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
49
59
  # @param output [IO] output stream (default: $stdout)
50
60
  # @return [void]
51
- def info(msg, output: $stdout)
52
- message(msg, symbol: Colors.blue(Symbols::S_INFO), output:)
61
+ def info(msg, with_guide: nil, output: $stdout)
62
+ message(msg, symbol: Colors.blue(Symbols::S_INFO), with_guide:, output:)
53
63
  end
54
64
 
55
65
  # Print a success message (green symbol).
56
66
  #
57
67
  # @param msg [String] the message to display
68
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
58
69
  # @param output [IO] output stream (default: $stdout)
59
70
  # @return [void]
60
- def success(msg, output: $stdout)
61
- message(msg, symbol: Colors.green(Symbols::S_SUCCESS), output:)
71
+ def success(msg, with_guide: nil, output: $stdout)
72
+ message(msg, symbol: Colors.green(Symbols::S_SUCCESS), with_guide:, output:)
62
73
  end
63
74
 
64
75
  # Print a step completion message (green submit symbol).
65
76
  #
66
77
  # @param msg [String] the message to display
78
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
67
79
  # @param output [IO] output stream (default: $stdout)
68
80
  # @return [void]
69
- def step(msg, output: $stdout)
70
- message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), output:)
81
+ def step(msg, with_guide: nil, output: $stdout)
82
+ message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), with_guide:, output:)
71
83
  end
72
84
 
73
85
  # Print a warning message (yellow symbol).
74
86
  #
75
87
  # @param msg [String] the message to display
88
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
76
89
  # @param output [IO] output stream (default: $stdout)
77
90
  # @return [void]
78
- def warn(msg, output: $stdout)
79
- message(msg, symbol: Colors.yellow(Symbols::S_WARN), output:)
91
+ def warn(msg, with_guide: nil, output: $stdout)
92
+ message(msg, symbol: Colors.yellow(Symbols::S_WARN), with_guide:, output:)
80
93
  end
81
94
  alias_method :warning, :warn
82
95
 
83
96
  # Print an error message (red symbol).
84
97
  #
85
98
  # @param msg [String] the message to display
99
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
86
100
  # @param output [IO] output stream (default: $stdout)
87
101
  # @return [void]
88
- def error(msg, output: $stdout)
89
- message(msg, symbol: Colors.red(Symbols::S_ERROR), output:)
102
+ def error(msg, with_guide: nil, output: $stdout)
103
+ message(msg, symbol: Colors.red(Symbols::S_ERROR), with_guide:, output:)
90
104
  end
91
105
  end
92
106
  end
data/lib/clack/note.rb CHANGED
@@ -6,27 +6,33 @@ module Clack
6
6
  class << self
7
7
  # Render a note box to the output stream.
8
8
  #
9
+ # With guides on, a rail line precedes the box and the bottom-left corner is
10
+ # a T-connector so the rail continues below. With guides off there is no
11
+ # leading rail and the corner is rounded (upstream behavior).
12
+ #
9
13
  # @param message [String] the note content
10
14
  # @param title [String, nil] optional title displayed above the box
15
+ # @param with_guide [Boolean, nil] show the guide rail (default: Clack.settings[:with_guide])
11
16
  # @param output [IO] output stream (default: $stdout)
12
17
  # @return [void]
13
- def render(message = "", title: nil, output: $stdout)
18
+ def render(message = "", title: nil, with_guide: nil, output: $stdout)
19
+ guide = Core::Settings.with_guide?(with_guide)
14
20
  lines = message.to_s.lines.map(&:chomp)
15
21
  # Add empty lines at start and end like original
16
22
  lines = ["", *lines, ""]
17
23
  title_len = Clack::Utils.visible_length(title)
18
24
  width = calculate_width(lines, title_len)
19
25
 
20
- output.puts Colors.gray(Symbols::S_BAR)
26
+ output.puts Colors.gray(Symbols::S_BAR) if guide
21
27
  output.puts build_top_border(title, title_len, width)
22
28
 
23
29
  lines.each do |line|
24
30
  pad = width - Clack::Utils.visible_length(line)
25
31
  padded = pad.positive? ? line + (" " * pad) : line
26
- output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.dim(padded)}#{Colors.gray(Symbols::S_BAR)}"
32
+ output.puts "#{Colors.gray(Symbols::S_BAR)} #{padded}#{Colors.gray(Symbols::S_BAR)}"
27
33
  end
28
34
 
29
- output.puts build_bottom_border(width)
35
+ output.puts build_bottom_border(width, guide)
30
36
  end
31
37
 
32
38
  private
@@ -48,9 +54,10 @@ module Clack
48
54
  end
49
55
  end
50
56
 
51
- def build_bottom_border(width)
57
+ def build_bottom_border(width, guide)
52
58
  border = Symbols::S_BAR_H * (width + 2)
53
- "#{Colors.gray(Symbols::S_CONNECT_LEFT)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_BOTTOM_RIGHT)}"
59
+ corner = guide ? Symbols::S_CONNECT_LEFT : Symbols::S_CORNER_BOTTOM_LEFT
60
+ "#{Colors.gray(corner)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_BOTTOM_RIGHT)}"
54
61
  end
55
62
  end
56
63
  end
@@ -107,23 +107,15 @@ module Clack
107
107
  end
108
108
 
109
109
  def build_frame
110
- lines = []
111
- lines << "#{bar}\n"
112
- lines << "#{symbol_for_state} #{@message}\n"
113
- lines << help_line
114
- lines << "#{active_bar} #{input_display}\n"
110
+ lines = [frame_header]
111
+ lines << "#{gutter(active_bar)}#{input_display}\n"
115
112
 
116
113
  visible_options.each_with_index do |opt, idx|
117
114
  actual_idx = @scroll_offset + idx
118
- lines << "#{bar} #{option_display(opt, actual_idx == @option_index)}\n"
119
- end
120
-
121
- if @state in :error | :warning
122
- lines.concat(validation_message_lines)
123
- else
124
- lines << "#{bar_end}\n"
115
+ lines << "#{gutter(active_bar)}#{option_display(opt, actual_idx == @option_index)}\n"
125
116
  end
126
117
 
118
+ lines << frame_footer
127
119
  lines.join
128
120
  end
129
121
 
@@ -141,6 +133,15 @@ module Clack
141
133
 
142
134
  def navigable_items = @filtered
143
135
 
136
+ # Upstream autocomplete footer
137
+ def keyboard_hints
138
+ [
139
+ key_hint(Symbols::S_ARROWS_UP_DOWN, "to select"),
140
+ key_hint("Enter:", "confirm"),
141
+ key_hint("Type:", "to search")
142
+ ]
143
+ end
144
+
144
145
  def option_display(opt, active)
145
146
  hint = (opt.hint && active) ? Colors.dim(" (#{opt.hint})") : ""
146
147
  if active
@@ -101,27 +101,16 @@ module Clack
101
101
  end
102
102
 
103
103
  def build_frame
104
- lines = []
105
- lines << "#{bar}\n"
106
- lines << "#{symbol_for_state} #{@message}\n"
107
- lines << help_line
108
- lines << "#{active_bar} #{Colors.dim("Search:")} #{input_display}#{match_count}\n"
104
+ lines = [frame_header]
105
+ lines << "#{gutter(active_bar)}#{Colors.dim("Search:")} #{input_display}#{match_count}\n"
109
106
 
110
107
  visible_options.each_with_index do |opt, idx|
111
108
  actual_idx = @scroll_offset + idx
112
- lines << "#{active_bar} #{option_display(opt, actual_idx == @option_index)}\n"
113
- end
114
-
115
- lines << "#{active_bar} #{Colors.yellow("No matches found")}\n" if @filtered.empty? && !@search_text.empty?
116
-
117
- lines << "#{active_bar} #{keyboard_hints}\n"
118
-
119
- if @state in :error | :warning
120
- lines.concat(validation_message_lines)
121
- else
122
- lines << "#{bar_end}\n"
109
+ lines << "#{gutter(active_bar)}#{option_display(opt, actual_idx == @option_index)}\n"
123
110
  end
124
111
 
112
+ lines << "#{gutter(active_bar)}#{Colors.yellow("No matches found")}\n" if @filtered.empty? && !@search_text.empty?
113
+ lines << frame_footer
125
114
  lines.join
126
115
  end
127
116
 
@@ -150,12 +139,14 @@ module Clack
150
139
  Colors.dim(" (#{@filtered.size} match#{"es" unless @filtered.size == 1})")
151
140
  end
152
141
 
142
+ # Upstream autocomplete multiselect footer, minus Tab (Ruby has no Tab-select).
153
143
  def keyboard_hints
154
- Colors.dim([
155
- "up/down: navigate",
156
- "space: select",
157
- "enter: confirm"
158
- ].join(" | "))
144
+ [
145
+ key_hint(Symbols::S_ARROWS_UP_DOWN, "to navigate"),
146
+ key_hint("Space:", "select"),
147
+ key_hint("Enter:", "confirm"),
148
+ key_hint("Type:", "to search")
149
+ ]
159
150
  end
160
151
 
161
152
  def update_filtered
@@ -18,17 +18,31 @@ module Clack
18
18
  # initial_value: false
19
19
  # )
20
20
  #
21
+ # @example Stacked layout for long labels
22
+ # Clack.confirm(
23
+ # message: "Overwrite ~/.zshrc?",
24
+ # active: "Yes, back it up and replace it",
25
+ # inactive: "No, keep my existing file",
26
+ # vertical: true
27
+ # )
28
+ #
21
29
  class Confirm < Core::Prompt
22
30
  # @param message [String] the prompt message
23
31
  # @param active [String] label for the "yes" option (default: "Yes")
24
32
  # @param inactive [String] label for the "no" option (default: "No")
25
- # @param initial_value [Boolean] initial selection (default: true)
33
+ # @param initial_value [Object] initial selection (default: true); coerced to a
34
+ # Boolean, so nil and false select "no" and anything else selects "yes"
35
+ # @param vertical [Boolean] render the two options on separate lines (default: false)
26
36
  # @param opts [Hash] additional options passed to {Core::Prompt}
27
- def initialize(message:, active: "Yes", inactive: "No", initial_value: true, **opts)
37
+ def initialize(message:, active: "Yes", inactive: "No", initial_value: true, vertical: false, **opts)
28
38
  super(message:, **opts)
29
39
  @active_label = active
30
40
  @inactive_label = inactive
31
- @value = initial_value
41
+ @vertical = vertical
42
+ # Coerce like upstream (`!!opts.initialValue`) so the prompt always
43
+ # returns a real Boolean, even when the caller passes nil from ENV or
44
+ # an options hash.
45
+ @value = initial_value ? true : false
32
46
  end
33
47
 
34
48
  protected
@@ -46,23 +60,27 @@ module Clack
46
60
  end
47
61
 
48
62
  def build_frame
49
- "#{frame_header}#{bar} #{options_display}\n#{frame_footer}"
63
+ "#{frame_header}#{row_prefix}#{options_display}\n#{frame_footer}"
50
64
  end
51
65
 
52
66
  def final_display = @value ? @active_label : @inactive_label
53
67
 
54
68
  private
55
69
 
70
+ # Gutter prefix for an option row. Both the first row and the vertical
71
+ # joiner use it, so the rail colour follows the prompt state in one place.
72
+ # Empty when guides are off, so the vertical joiner collapses to a bare newline.
73
+ def row_prefix = gutter(active_bar)
74
+
56
75
  def options_display
57
- if @value
58
- active = "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{@active_label}"
59
- inactive = "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(@inactive_label)}"
60
- else
61
- active = "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(@active_label)}"
62
- inactive = "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{@inactive_label}"
63
- end
76
+ joiner = @vertical ? "\n#{row_prefix}" : " #{Colors.dim("/")} "
77
+ "#{radio(@active_label, @value)}#{joiner}#{radio(@inactive_label, !@value)}"
78
+ end
79
+
80
+ def radio(label, selected)
81
+ return "#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{label}" if selected
64
82
 
65
- "#{active} #{Colors.dim("/")} #{inactive}"
83
+ "#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(label)}"
66
84
  end
67
85
  end
68
86
  end
@@ -83,7 +83,7 @@ module Clack
83
83
  end
84
84
 
85
85
  def build_frame
86
- "#{frame_header}#{active_bar} #{date_display}\n#{frame_footer}"
86
+ "#{frame_header}#{gutter(active_bar)}#{date_display}\n#{frame_footer}"
87
87
  end
88
88
 
89
89
  def final_display = formatted_date