clack 0.2.0 → 0.4.3

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: 9b2beeffde4af3c88491d8b659dd781e8bd3e652e06f8c159a3ca7910fc69c87
4
- data.tar.gz: 86b779a202099d27cf702009ee649e19a49b11305e10d2033cf7a0425fe38e10
3
+ metadata.gz: 1a59e5250ad3fb130ffda74b6fdfa5bda7bb4f57062cd6305c5c07f0af7b82ee
4
+ data.tar.gz: 8fb2da61ab0120a8f95189c9112463e742d87a7a915a30d9c5ae7d192c90475b
5
5
  SHA512:
6
- metadata.gz: 9f8b20f670252b17132fcb664eeeaa3ba06b8a8fa0abbee5fdb8e40d17460f2e59badeda5b11b20aaa82b7e9b4b0fef6d06524933794dd20a3c6fcd159cc2115
7
- data.tar.gz: 21642c6a8cea0671cff152932e806ea7a83163612ecd35739fa2e2bd98179b5e5b714ae6de3e5a2153206ac5f3b52f02ec2e28fc82541056a726d60071afbdab
6
+ metadata.gz: f64a271d61f1e0b93938b418d2907a173cde44a901a98ba1f691b3c45c84e97ef8096e6b1a1b6e7b94470287920340f75df65ec62a213b661707ab935f266ead
7
+ data.tar.gz: 6bf0a82964715fa130c64f15dc974a373aa00e996d285e19cf0518175b9916ff042b2fe7a042e1ea3d5720d3dee20c1fdbd914d5a77fd9f5a1f428f24622f7ee
data/CHANGELOG.md CHANGED
@@ -1,5 +1,79 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.3] - 2026-02-21
4
+
5
+ ### Fixed
6
+ - Multiselect variants now ignore `initial_values` that don't match any option (prevents phantom values in return)
7
+ - SIGWINCH handler now uses `.dup` for safe array iteration inside signal trap
8
+ - `GroupMultiselect` propagates `:hint` into flat items so hints actually render
9
+
10
+ ### Changed
11
+ - `AutocompleteMultiselect` final frame now shows selected labels instead of "N items selected", matching `Multiselect` behavior
12
+ - Removed dead `@mutex` from `Testing` module
13
+ - Removed unused `raw:` parameter from `TaskLog` and `TaskLogGroup` message methods
14
+
15
+ ## [0.4.2] - 2026-02-20
16
+
17
+ ### Fixed
18
+ - `GroupMultiselect` now preserves `:hint` on options (was silently dropped during normalization)
19
+ - `GroupMultiselect` renders hints on active options, matching `Select` behavior
20
+ - `Spinner` no longer raises when finished in timer mode before the animation thread starts
21
+ - YARD docs: corrected `selectable_groups` default from `true` to `false`
22
+
23
+ ### Changed
24
+ - `Range` prompt now accepts `initial_value:` for consistency with all other prompts (`default:` still works)
25
+ - README: corrected Range example to use `initial_value:` and tab completion description
26
+
27
+ ## [0.4.1] - 2026-02-20
28
+
29
+ ### Fixed
30
+ - `AutocompleteMultiselect` now renders warning validation messages (was error-only)
31
+ - CI mode no longer writes ANSI escape codes to non-TTY output
32
+ - CI mode prints a warning when validation fails instead of silently returning
33
+ - Spinner animation restarts from frame 0 when reused
34
+ - `Environment.raw_mode_supported?` catches specific exceptions instead of bare rescue
35
+ - `FuzzyMatcher.filter` pre-computes downcased query (performance optimization for large lists)
36
+ - Removed dead instance variables in Path, Testing, and Spinner
37
+ - Simplified `Range#clamp` by removing unreachable branch
38
+
39
+ ### Changed
40
+ - Updated gem dependencies (rubocop 1.84, standard 1.54, prism 1.9, bigdecimal 4.0)
41
+ - Autocomplete YARD docs corrected: default filter is fuzzy matching, not substring
42
+ - Expanded YARD `@option` documentation for spinner, multiselect, group_multiselect, path, tasks
43
+ - README and ARCHITECTURE.md updated to cover all v0.3.0-v0.4.0 features
44
+
45
+ ## [0.4.0] - 2026-02-19
46
+
47
+ ### Added
48
+ - `range` slider prompt for numeric selection (`Clack.range(message:, min:, max:, step:, initial_value:)`)
49
+ - Tab completion on `text` prompt via `completions:` parameter (array or proc)
50
+ - Minimum terminal width warning (non-blocking, 40 columns)
51
+
52
+ ### Changed
53
+ - Path prompt caches directory listings to avoid repeated filesystem scans on every keystroke
54
+
55
+ ## [0.3.0] - 2026-02-19
56
+
57
+ ### Added
58
+ - `Clack::Testing` module with first-class test helpers (`simulate`, `simulate_with_output`, `PromptDriver`)
59
+ - `Clack::Core::FuzzyMatcher` with scored fuzzy matching (consecutive/boundary/start bonuses)
60
+ - CI / non-interactive mode: `Clack.update_settings(ci_mode: true)` or `:auto` to auto-detect
61
+ - `autocomplete_multiselect` now accepts `filter:` proc for custom matching logic
62
+
63
+ ### Changed
64
+ - Autocomplete prompts default to fuzzy matching instead of substring matching
65
+ - Spinner is now thread-safe: guards against double-finish, protects `@cancelled` reads with mutex
66
+
67
+ ## [0.2.1] - 2026-02-19
68
+
69
+ ### Added
70
+ - `Core::ScrollHelper` mixin extracted from scroll/filter logic across 3 prompts
71
+
72
+ ### Changed
73
+ - `TextInputHelper` parameterized via `text_value`/`text_value=` for custom backing stores
74
+ - Tasks prompt now reuses `Core::Spinner` instead of inline spinner implementation
75
+ - Removed redundant `@value = nil` from SelectKey
76
+
3
77
  ## [0.2.0] - 2026-02-19
4
78
 
5
79
  ### Added
data/README.md CHANGED
@@ -193,7 +193,24 @@ name = Clack.text(
193
193
  placeholder: "my-project", # Shown when empty (dim)
194
194
  default_value: "untitled", # Used if submitted empty
195
195
  initial_value: "hello-world", # Pre-filled, editable
196
- validate: ->(v) { "Required!" if v.empty? }
196
+ validate: ->(v) { "Required!" if v.empty? },
197
+ help: "Letters, numbers, and dashes only" # Contextual help text
198
+ )
199
+ ```
200
+
201
+ **Tab completion** - press `Tab` to fill the longest common prefix of matching candidates:
202
+
203
+ ```ruby
204
+ # Tab completion from a static list
205
+ cmd = Clack.text(
206
+ message: "Command?",
207
+ completions: %w[build test deploy lint format]
208
+ )
209
+
210
+ # Dynamic tab completion
211
+ file = Clack.text(
212
+ message: "File?",
213
+ completions: ->(input) { Dir.glob("#{input}*") }
197
214
  )
198
215
  ```
199
216
 
@@ -276,7 +293,7 @@ features = Clack.multiselect(
276
293
 
277
294
  ### Autocomplete
278
295
 
279
- Type to filter from a list of options.
296
+ Type to filter from a list of options. Filtering uses **fuzzy matching** by default -- characters must appear in order but don't need to be consecutive (e.g. "fb" matches "foobar"). Pass `filter:` to override with custom logic.
280
297
 
281
298
  ```ruby
282
299
  color = Clack.autocomplete(
@@ -340,6 +357,20 @@ date = Clack.date(
340
357
 
341
358
  **Navigation:** `Tab`/`←→` between segments | `↑↓` adjust value | type digits directly
342
359
 
360
+ ### Range
361
+
362
+ Numeric selection with a visual slider track. Navigate with `←→` or `↑↓` arrow keys (or `hjkl`).
363
+
364
+ ```ruby
365
+ volume = Clack.range(
366
+ message: "Set volume",
367
+ min: 0,
368
+ max: 100,
369
+ step: 5,
370
+ initial_value: 50
371
+ )
372
+ ```
373
+
343
374
  ### Select Key
344
375
 
345
376
  Quick selection using keyboard shortcuts.
@@ -583,8 +614,51 @@ Clack.update_settings(aliases: { "y" => :enter, "n" => :cancel })
583
614
 
584
615
  # Disable guide bars
585
616
  Clack.update_settings(with_guide: false)
617
+
618
+ # CI / non-interactive mode (prompts auto-submit with defaults)
619
+ Clack.update_settings(ci_mode: true) # Always on
620
+ Clack.update_settings(ci_mode: :auto) # Auto-detect (non-TTY or CI env vars)
621
+ ```
622
+
623
+ When CI mode is active, prompts immediately submit with their default values instead of waiting for input. Useful for CI pipelines and scripted environments where stdin is not a TTY.
624
+
625
+ Clack also warns when terminal width is below 40 columns, since prompts may not render cleanly in very narrow terminals.
626
+
627
+ ## Testing
628
+
629
+ Clack ships with first-class test helpers. Require `clack/testing` explicitly (it is not auto-loaded):
630
+
631
+ ```ruby
632
+ require "clack/testing"
633
+
634
+ # Simulate a text prompt
635
+ result = Clack::Testing.simulate(Clack.method(:text), message: "Name?") do |prompt|
636
+ prompt.type("Alice")
637
+ prompt.submit
638
+ end
639
+ # => "Alice"
640
+
641
+ # Capture rendered output alongside the result
642
+ result, output = Clack::Testing.simulate_with_output(Clack.method(:confirm), message: "Sure?") do |prompt|
643
+ prompt.left # switch to "No"
644
+ prompt.submit
645
+ end
586
646
  ```
587
647
 
648
+ The `PromptDriver` yielded to the block provides these methods:
649
+
650
+ | Method | Description |
651
+ |---|---|
652
+ | `type(text)` | Type a string character by character |
653
+ | `submit` | Press Enter |
654
+ | `cancel` | Press Escape |
655
+ | `up` / `down` / `left` / `right` | Arrow keys |
656
+ | `toggle` | Press Space (for multiselect) |
657
+ | `tab` | Press Tab |
658
+ | `backspace` | Press Backspace |
659
+ | `ctrl_d` | Press Ctrl+D (submit multiline text) |
660
+ | `key(sym_or_char)` | Press an arbitrary key by symbol (e.g. `:escape`) or raw character |
661
+
588
662
  ## Requirements
589
663
 
590
664
  - Ruby 3.2+
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Non-interactive mode for CI environments and piped input.
6
+ #
7
+ # When enabled, prompts auto-submit with their default values instead
8
+ # of waiting for user input. Useful for CI pipelines, automated testing,
9
+ # and scripted usage where stdin isn't a TTY.
10
+ #
11
+ # Enable explicitly:
12
+ # Clack.update_settings(ci_mode: true)
13
+ #
14
+ # Or auto-detect (non-TTY stdin or CI environment variable):
15
+ # Clack.update_settings(ci_mode: :auto)
16
+ module CiMode
17
+ class << self
18
+ # Check if CI mode is currently active.
19
+ #
20
+ # @return [Boolean]
21
+ def active?
22
+ setting = Settings.config[:ci_mode]
23
+ case setting
24
+ when true
25
+ true
26
+ when :auto
27
+ !Environment.tty?($stdin) || Environment.ci?
28
+ else
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Simple scored fuzzy matcher for autocomplete prompts.
6
+ #
7
+ # Matches query characters in order within the target string, scoring
8
+ # higher for consecutive matches and matches at word boundaries.
9
+ # Dependency-free alternative to Levenshtein distance that's fast
10
+ # enough for interactive use.
11
+ #
12
+ # @example Basic matching
13
+ # FuzzyMatcher.match?("fb", "foobar") # => true
14
+ # FuzzyMatcher.match?("zz", "foobar") # => false
15
+ #
16
+ # @example Scoring
17
+ # FuzzyMatcher.score("fb", "foobar") # => 2 (non-consecutive)
18
+ # FuzzyMatcher.score("foo", "foobar") # => 9 (consecutive + start)
19
+ #
20
+ # @example Filtering and sorting options
21
+ # FuzzyMatcher.filter(options, "fb") # => sorted by relevance
22
+ module FuzzyMatcher
23
+ # Bonus for match at the very start of the string
24
+ START_BONUS = 3
25
+ # Bonus for each consecutive character matched
26
+ CONSECUTIVE_BONUS = 2
27
+ # Bonus for match at a word boundary (after space, _, -)
28
+ BOUNDARY_BONUS = 2
29
+ # Base score per matched character
30
+ BASE_SCORE = 1
31
+
32
+ class << self
33
+ # Check if query fuzzy-matches the target string.
34
+ #
35
+ # @param query [String] the search query
36
+ # @param target [String] the string to match against
37
+ # @return [Boolean] true if all query chars appear in order in target
38
+ def match?(query, target)
39
+ return true if query.empty?
40
+
41
+ qi = 0
42
+ q_chars = query.downcase
43
+ t_chars = target.downcase
44
+
45
+ t_chars.each_char do |tc|
46
+ qi += 1 if tc == q_chars[qi]
47
+ return true if qi >= q_chars.length
48
+ end
49
+
50
+ false
51
+ end
52
+
53
+ # Score a fuzzy match. Higher is better. Returns 0 if no match.
54
+ #
55
+ # @param query [String] the search query
56
+ # @param target [String] the string to score against
57
+ # @param q_down [String, nil] pre-downcased query (optimization for batch use)
58
+ # @return [Integer] match score (0 = no match)
59
+ def score(query, target, q_down: nil)
60
+ return 0 if query.empty?
61
+
62
+ q_down ||= query.downcase
63
+ t_down = target.downcase
64
+ qi = 0
65
+ total = 0
66
+ prev_match_idx = -2 # -2 so first match at 0 isn't consecutive
67
+
68
+ t_down.each_char.with_index do |tc, ti|
69
+ next unless qi < q_down.length && tc == q_down[qi]
70
+
71
+ total += BASE_SCORE
72
+ total += START_BONUS if ti.zero?
73
+ total += CONSECUTIVE_BONUS if ti == prev_match_idx + 1
74
+ total += BOUNDARY_BONUS if ti.positive? && boundary?(t_down[ti - 1])
75
+
76
+ prev_match_idx = ti
77
+ qi += 1
78
+ end
79
+
80
+ (qi >= q_down.length) ? total : 0
81
+ end
82
+
83
+ # Filter and sort option hashes by fuzzy relevance.
84
+ #
85
+ # Matches against label, value (as string), and hint fields.
86
+ # Returns options sorted by best match score (descending).
87
+ #
88
+ # @param options [Array<Hash>] normalized option hashes
89
+ # @param query [String] the search query
90
+ # @return [Array<Hash>] matching options sorted by relevance
91
+ def filter(options, query)
92
+ return options if query.empty?
93
+
94
+ q_down = query.downcase
95
+
96
+ scored = options.filter_map do |opt|
97
+ s = best_score(opt, query, q_down)
98
+ [opt, s] if s.positive?
99
+ end
100
+
101
+ scored.sort_by { |_, s| -s }.map(&:first)
102
+ end
103
+
104
+ private
105
+
106
+ def boundary?(char)
107
+ char == " " || char == "_" || char == "-" || char == "/"
108
+ end
109
+
110
+ def best_score(opt, query, q_down)
111
+ scores = [
112
+ score(query, opt[:label], q_down: q_down),
113
+ score(query, opt[:value].to_s, q_down: q_down)
114
+ ]
115
+ scores << score(query, opt[:hint], q_down: q_down) if opt[:hint]
116
+ scores.max
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -27,10 +27,11 @@ module Clack
27
27
  # end
28
28
  #
29
29
  class Prompt
30
+ # Minimum terminal width for clean rendering.
31
+ # Prompts warn (non-blocking) if the terminal is narrower.
32
+ MIN_TERMINAL_WIDTH = 40
33
+
30
34
  # Track active prompts for SIGWINCH notification.
31
- # Signal handler may fire during register/unregister. We can't use
32
- # .dup (allocates, forbidden in trap context) so we accept a benign
33
- # race: worst case, a prompt misses one resize notification.
34
35
  @active_prompts = []
35
36
 
36
37
  class << self
@@ -53,7 +54,7 @@ module Clack
53
54
  return unless Signal.list.key?("WINCH")
54
55
 
55
56
  Signal.trap("WINCH") do
56
- @active_prompts.each(&:request_redraw)
57
+ @active_prompts.dup.each(&:request_redraw)
57
58
  end
58
59
  end
59
60
  end
@@ -103,24 +104,32 @@ module Clack
103
104
  #
104
105
  # @return [Object, Clack::CANCEL] the submitted value or CANCEL sentinel
105
106
  def run
107
+ return run_ci_mode if CiMode.active?
108
+
106
109
  Prompt.register(self)
107
- setup_terminal
108
- render
109
- @state = :active
110
+ warn_narrow_terminal
111
+ terminal_setup = false
110
112
 
111
- loop do
112
- key = KeyReader.read
113
- dispatch_key(key)
113
+ begin
114
+ setup_terminal
115
+ terminal_setup = true
114
116
  render
117
+ @state = :active
115
118
 
116
- break if terminal_state?
117
- end
119
+ loop do
120
+ key = KeyReader.read
121
+ dispatch_key(key)
122
+ render
118
123
 
119
- finalize
120
- (terminal_state? && @state == :cancel) ? CANCEL : @value
121
- ensure
122
- Prompt.unregister(self)
123
- cleanup_terminal
124
+ break if terminal_state?
125
+ end
126
+
127
+ finalize
128
+ (terminal_state? && @state == :cancel) ? CANCEL : @value
129
+ ensure
130
+ Prompt.unregister(self)
131
+ cleanup_terminal if terminal_setup
132
+ end
124
133
  end
125
134
 
126
135
  protected
@@ -321,8 +330,30 @@ module Clack
321
330
  %i[submit cancel].include?(@state)
322
331
  end
323
332
 
333
+ # Auto-submit with current defaults in CI/non-interactive mode.
334
+ # Subclass submit overrides (e.g., Text applying default_value) run normally.
335
+ # Validation and transforms are applied. Returns the value regardless of
336
+ # validation outcome since there's no interactive way to fix input.
337
+ def run_ci_mode
338
+ submit
339
+ if @state == :error
340
+ @output.print "#{Colors.yellow("!")} #{Colors.yellow("CI mode: validation failed for")} \"#{@message}\": #{@error_message}\n"
341
+ end
342
+ @value
343
+ end
344
+
324
345
  private
325
346
 
347
+ def warn_narrow_terminal
348
+ return unless Environment.tty?(@output)
349
+
350
+ cols = Environment.columns(@output)
351
+ return if cols >= MIN_TERMINAL_WIDTH
352
+
353
+ @output.print "#{Colors.yellow("!")} #{Colors.yellow("Terminal is narrow")} " \
354
+ "(#{cols} cols, #{MIN_TERMINAL_WIDTH} recommended)\n"
355
+ end
356
+
326
357
  def setup_terminal
327
358
  @output.print Cursor.hide
328
359
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clack
4
+ module Core
5
+ # Shared scroll/navigation logic for filterable option lists.
6
+ #
7
+ # Used by prompts that have a dynamically filtered list (Autocomplete,
8
+ # AutocompleteMultiselect, Path) where the user navigates a subset of
9
+ # options that changes as they type.
10
+ #
11
+ # Including classes must define:
12
+ # - +@max_items+ [Integer] maximum visible items
13
+ # - +@selected_index+ [Integer] current selection index
14
+ # - +@scroll_offset+ [Integer] current scroll position
15
+ #
16
+ # Including classes must implement:
17
+ # - +scroll_items+ [Array] returns the current filterable list
18
+ module ScrollHelper
19
+ # Get the currently visible slice of items based on scroll position.
20
+ #
21
+ # @return [Array] visible items
22
+ def visible_items
23
+ items = scroll_items
24
+ return items if items.length <= @max_items
25
+
26
+ items[@scroll_offset, @max_items]
27
+ end
28
+
29
+ # Move the selection index by delta, wrapping around.
30
+ #
31
+ # @param delta [Integer] direction (+1 for down, -1 for up)
32
+ def move_selection(delta)
33
+ items = scroll_items
34
+ return if items.empty?
35
+
36
+ @selected_index = (@selected_index + delta) % items.length
37
+ update_selection_scroll
38
+ end
39
+
40
+ private
41
+
42
+ # Update scroll offset to keep the selected index visible.
43
+ def update_selection_scroll
44
+ return unless scroll_items.length > @max_items
45
+
46
+ if @selected_index < @scroll_offset
47
+ @scroll_offset = @selected_index
48
+ elsif @selected_index >= @scroll_offset + @max_items
49
+ @scroll_offset = @selected_index - @max_items + 1
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -40,7 +40,8 @@ module Clack
40
40
  # Global configuration (mutable)
41
41
  @config = {
42
42
  aliases: ALIASES.dup,
43
- with_guide: true
43
+ with_guide: true,
44
+ ci_mode: false
44
45
  }
45
46
  @config_mutex = Mutex.new
46
47
 
@@ -54,11 +55,13 @@ module Clack
54
55
  # Update global settings
55
56
  # @param aliases [Hash, nil] Custom key to action mappings (merged with defaults)
56
57
  # @param with_guide [Boolean, nil] Whether to show guide bars
58
+ # @param ci_mode [Boolean, Symbol, nil] CI mode: true (always), :auto (detect), false (never)
57
59
  # @return [Hash] Updated configuration
58
- def update(aliases: nil, with_guide: nil)
60
+ def update(aliases: nil, with_guide: nil, ci_mode: nil)
59
61
  @config_mutex.synchronize do
60
62
  @config[:aliases] = ALIASES.merge(aliases) if aliases
61
63
  @config[:with_guide] = with_guide unless with_guide.nil?
64
+ @config[:ci_mode] = ci_mode unless ci_mode.nil?
62
65
  @config.dup
63
66
  end
64
67
  end
@@ -68,7 +71,8 @@ module Clack
68
71
  @config_mutex.synchronize do
69
72
  @config = {
70
73
  aliases: ALIASES.dup,
71
- with_guide: true
74
+ with_guide: true,
75
+ ci_mode: false
72
76
  }
73
77
  end
74
78
  end
@@ -4,12 +4,16 @@ module Clack
4
4
  module Core
5
5
  # Shared functionality for text input prompts (Text, Autocomplete, Path).
6
6
  # Handles cursor display, placeholder rendering, and text manipulation.
7
+ #
8
+ # By default operates on +@value+ and +@cursor+. Override
9
+ # +text_value+ and +text_value=+ in your class to use a different
10
+ # backing store (e.g. +@search_text+ in AutocompleteMultiselect).
7
11
  module TextInputHelper
8
12
  # Display the input field with cursor or placeholder.
9
13
  #
10
14
  # @return [String] Formatted input display
11
15
  def input_display
12
- return placeholder_display if @value.empty?
16
+ return placeholder_display if text_value.empty?
13
17
 
14
18
  value_with_cursor
15
19
  end
@@ -45,8 +49,9 @@ module Clack
45
49
  #
46
50
  # @return [String] Value with cursor
47
51
  def value_with_cursor
48
- chars = @value.grapheme_clusters
49
- return "#{@value}#{cursor_block}" if @cursor >= chars.length
52
+ val = text_value
53
+ chars = val.grapheme_clusters
54
+ return "#{val}#{cursor_block}" if @cursor >= chars.length
50
55
 
51
56
  before = chars[0...@cursor].join
52
57
  current = Colors.inverse(chars[@cursor])
@@ -55,7 +60,6 @@ module Clack
55
60
  end
56
61
 
57
62
  # Handle text input key (backspace/delete or printable character).
58
- # Requires @value and @cursor instance variables.
59
63
  # Uses grapheme clusters for proper Unicode handling.
60
64
  #
61
65
  # @param key [String] The key pressed
@@ -64,21 +68,33 @@ module Clack
64
68
  return handle_backspace if Core::Settings.backspace?(key)
65
69
  return false unless Core::Settings.printable?(key)
66
70
 
67
- chars = @value.grapheme_clusters
71
+ chars = text_value.grapheme_clusters
68
72
  chars.insert(@cursor, key)
69
- @value = chars.join
73
+ self.text_value = chars.join
70
74
  @cursor += 1
71
75
  true
72
76
  end
73
77
 
78
+ # The text value being edited. Override to use a different backing store.
79
+ # @return [String]
80
+ def text_value
81
+ @value
82
+ end
83
+
84
+ # Set the text value. Override to use a different backing store.
85
+ # @param val [String]
86
+ def text_value=(val)
87
+ @value = val
88
+ end
89
+
74
90
  private
75
91
 
76
92
  def handle_backspace
77
93
  return false if @cursor.zero?
78
94
 
79
- chars = @value.grapheme_clusters
95
+ chars = text_value.grapheme_clusters
80
96
  chars.delete_at(@cursor - 1)
81
- @value = chars.join
97
+ self.text_value = chars.join
82
98
  @cursor -= 1
83
99
  true
84
100
  end
@@ -119,7 +119,7 @@ module Clack
119
119
  if windows? && !windows_terminal?
120
120
  begin
121
121
  IO.console&.respond_to?(:raw)
122
- rescue
122
+ rescue IOError, SystemCallError
123
123
  false
124
124
  end
125
125
  else