charming 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/charming/application.rb +91 -14
- data/lib/charming/application_state.rb +23 -0
- data/lib/charming/cli.rb +2 -2
- data/lib/charming/controller/action_hooks.rb +5 -1
- data/lib/charming/controller/class_methods.rb +66 -15
- data/lib/charming/controller/component_dispatch.rb +163 -0
- data/lib/charming/controller/dispatching.rb +1 -2
- data/lib/charming/controller/focus_management.rb +67 -1
- data/lib/charming/controller/key_dispatch.rb +7 -7
- data/lib/charming/controller/rendering.rb +22 -6
- data/lib/charming/controller/session_state.rb +63 -22
- data/lib/charming/controller.rb +247 -59
- data/lib/charming/cross_thread_access.rb +9 -0
- data/lib/charming/double_render_error.rb +8 -0
- data/lib/charming/generators/layout_generator.rb +4 -1
- data/lib/charming/generators/migration_generator.rb +1 -1
- data/lib/charming/generators/model_generator.rb +2 -2
- data/lib/charming/generators/name.rb +1 -1
- data/lib/charming/generators/screen_generator.rb +2 -2
- data/lib/charming/generators/view_generator.rb +1 -1
- data/lib/charming/internal/deep_freeze.rb +23 -0
- data/lib/charming/internal/env_inquirer.rb +22 -0
- data/lib/charming/internal/inflections.rb +93 -0
- data/lib/charming/internal/session_guard.rb +27 -0
- data/lib/charming/internal/terminal/cursor.rb +29 -0
- data/lib/charming/internal/terminal/size.rb +47 -0
- data/lib/charming/internal/terminal/tty_backend.rb +10 -8
- data/lib/charming/presentation/components/autocomplete.rb +14 -6
- data/lib/charming/presentation/components/command_palette.rb +11 -9
- data/lib/charming/presentation/components/filepicker.rb +4 -4
- data/lib/charming/presentation/components/form/confirm.rb +2 -1
- data/lib/charming/presentation/components/form/field.rb +1 -1
- data/lib/charming/presentation/components/form/input.rb +3 -3
- data/lib/charming/presentation/components/form/multiselect.rb +4 -5
- data/lib/charming/presentation/components/form/select.rb +3 -3
- data/lib/charming/presentation/components/form/textarea.rb +3 -3
- data/lib/charming/presentation/components/form.rb +6 -6
- data/lib/charming/presentation/components/help_overlay.rb +2 -2
- data/lib/charming/presentation/components/keyboard_handler.rb +3 -3
- data/lib/charming/presentation/components/list.rb +14 -5
- data/lib/charming/presentation/components/modal.rb +3 -2
- data/lib/charming/presentation/components/multi_select_list.rb +14 -7
- data/lib/charming/presentation/components/result.rb +61 -0
- data/lib/charming/presentation/components/tab_bar.rb +14 -6
- data/lib/charming/presentation/components/table.rb +64 -32
- data/lib/charming/presentation/components/text_area.rb +7 -7
- data/lib/charming/presentation/components/text_input.rb +7 -7
- data/lib/charming/presentation/components/tree.rb +15 -6
- data/lib/charming/presentation/components/viewport.rb +3 -3
- data/lib/charming/presentation/layout/pane.rb +6 -2
- data/lib/charming/presentation/layout/screen_layout.rb +7 -0
- data/lib/charming/presentation/view.rb +35 -29
- data/lib/charming/render_artifacts.rb +24 -0
- data/lib/charming/response.rb +19 -8
- data/lib/charming/router.rb +50 -68
- data/lib/charming/runtime.rb +42 -26
- data/lib/charming/{controller/command_palette.rb → shell/palette.rb} +43 -11
- data/lib/charming/{controller/sidebar_navigation.rb → shell/sidebar.rb} +11 -11
- data/lib/charming/tasks/context.rb +35 -0
- data/lib/charming/test_helper.rb +42 -22
- data/lib/charming/unhandled_component_event.rb +9 -0
- data/lib/charming/unknown_slot.rb +9 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome.rb +1 -1
- data/lib/charming.rb +14 -6
- metadata +21 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
# DeepFreeze returns a deep-frozen copy of a value: strings, arrays, hashes, and
|
|
6
|
+
# sets are duplicated (recursively) and frozen; numbers, symbols, and nil pass
|
|
7
|
+
# through as-is (already immutable); anything else (IO, models, components) passes
|
|
8
|
+
# through unfrozen — it has no sane freeze semantics across a thread boundary.
|
|
9
|
+
# The caller's originals are never frozen.
|
|
10
|
+
module DeepFreeze
|
|
11
|
+
# Returns a deep-frozen copy of *value* per the rules above.
|
|
12
|
+
def self.call(value)
|
|
13
|
+
case value
|
|
14
|
+
when Hash then value.to_h { |key, element| [call(key), call(element)] }.freeze
|
|
15
|
+
when Array then value.map { |element| call(element) }.freeze
|
|
16
|
+
when Set then value.map { |element| call(element) }.to_set.freeze
|
|
17
|
+
when String then value.frozen? ? value : value.dup.freeze
|
|
18
|
+
else value
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
# A String that answers predicates about its own value:
|
|
6
|
+
# `EnvInquirer.new("development").development?` → true. Replaces
|
|
7
|
+
# ActiveSupport::StringInquirer for `Charming.env`.
|
|
8
|
+
class EnvInquirer < String
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def method_missing(name, *)
|
|
12
|
+
return self == name.to_s.delete_suffix("?") if name.end_with?("?")
|
|
13
|
+
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def respond_to_missing?(name, include_private = false)
|
|
18
|
+
name.end_with?("?") || super
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
# String inflection helpers covering exactly what Charming needs, with
|
|
6
|
+
# ActiveSupport-compatible semantics for the inputs Charming produces
|
|
7
|
+
# (snake_case identifiers and "A::B" constant paths). Replaces the former
|
|
8
|
+
# ActiveSupport::Inflector dependency. `pluralize` implements a deliberate
|
|
9
|
+
# subset of English rules — enough for conventional resource names — not
|
|
10
|
+
# ActiveSupport's full inflection table.
|
|
11
|
+
module Inflections
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# "weather_report" → "WeatherReport"; "admin/users" → "Admin::Users".
|
|
15
|
+
def camelize(term)
|
|
16
|
+
string = term.to_s.sub(/\A[a-z\d]*/, &:capitalize)
|
|
17
|
+
string.gsub(%r{(?:_|(/))([a-z\d]*)}i) { "#{Regexp.last_match(1) && "::"}#{Regexp.last_match(2).capitalize}" }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# "HomeController" → "home_controller"; "MyApp::Home" → "my_app/home";
|
|
21
|
+
# acronym runs get a boundary before a capitalized word ("HTMLTidy" → "html_tidy").
|
|
22
|
+
def underscore(camel_cased_word)
|
|
23
|
+
word = camel_cased_word.to_s.gsub("::", "/")
|
|
24
|
+
word.gsub!(/([A-Z\d]+)(?=[A-Z][a-z])|([a-z\d])(?=[A-Z])/) { "#{Regexp.last_match(1) || Regexp.last_match(2)}_" }
|
|
25
|
+
word.tr!("-", "_")
|
|
26
|
+
word.downcase!
|
|
27
|
+
word
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# "MyApp::HomeController" → "HomeController".
|
|
31
|
+
def demodulize(path)
|
|
32
|
+
path.to_s.split("::").last
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# "MyApp::Application" → "MyApp"; "Application" → "".
|
|
36
|
+
def deconstantize(path)
|
|
37
|
+
path.to_s[0, path.to_s.rindex("::") || 0]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# "Charming::Router" → Charming::Router. Raises NameError on a miss.
|
|
41
|
+
def constantize(name)
|
|
42
|
+
Object.const_get(name)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# "user_name" → "User name"; "author_id" → "Author". Assumes snake_case input.
|
|
46
|
+
def humanize(lower_case_and_underscored_word)
|
|
47
|
+
result = lower_case_and_underscored_word.to_s.sub(/_id\z/, "").tr("_", " ")
|
|
48
|
+
result.sub(/\A\w/, &:upcase)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# "category" → "categories"; "person" → "people". A subset of English
|
|
52
|
+
# rules covering conventional resource names; exotic words may need the
|
|
53
|
+
# generated migration renamed by hand.
|
|
54
|
+
def pluralize(word)
|
|
55
|
+
result = word.to_s.dup
|
|
56
|
+
return result if UNCOUNTABLE.include?(result)
|
|
57
|
+
|
|
58
|
+
irregular = IRREGULAR_FORMS[result.split("_").last]
|
|
59
|
+
return result.sub(/[^_]+\z/, irregular) if irregular
|
|
60
|
+
|
|
61
|
+
PLURAL_RULES.each do |pattern, replacement|
|
|
62
|
+
return result.sub(pattern, replacement) if result.match?(pattern)
|
|
63
|
+
end
|
|
64
|
+
result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
IRREGULAR_FORMS = {
|
|
68
|
+
"child" => "children",
|
|
69
|
+
"man" => "men",
|
|
70
|
+
"mouse" => "mice",
|
|
71
|
+
"person" => "people",
|
|
72
|
+
"sex" => "sexes",
|
|
73
|
+
"woman" => "women"
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
UNCOUNTABLE = %w[equipment information money rice series sheep species].freeze
|
|
77
|
+
|
|
78
|
+
# Ordered: the first matching rule wins. Mirrors the head of
|
|
79
|
+
# ActiveSupport's plural rule list for the inputs generators produce.
|
|
80
|
+
PLURAL_RULES = [
|
|
81
|
+
[/(quiz)\z/, '\1zes'],
|
|
82
|
+
[/(matr|vert|ind)(ix|ex)\z/, '\1ices'],
|
|
83
|
+
[/(x|ch|ss|sh)\z/, '\1es'],
|
|
84
|
+
[/([^aeiouy]|qu)y\z/, '\1ies'],
|
|
85
|
+
[/sis\z/, "ses"],
|
|
86
|
+
[/([ti])um\z/, '\1a'],
|
|
87
|
+
[/(buffal|tomat|potat|her)o\z/, '\1oes'],
|
|
88
|
+
[/s\z/, "s"],
|
|
89
|
+
[/\z/, "s"]
|
|
90
|
+
].freeze
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Internal
|
|
7
|
+
# SessionGuard wraps the application session hash and asserts every access happens
|
|
8
|
+
# on the controller's loop thread. Controller#session returns this wrapper in
|
|
9
|
+
# development and test; production gets the raw hash (a warning is logged instead
|
|
10
|
+
# of a raise).
|
|
11
|
+
class SessionGuard < SimpleDelegator
|
|
12
|
+
def initialize(session, controller)
|
|
13
|
+
super(session)
|
|
14
|
+
@controller = controller
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def method_missing(name, *args, &block)
|
|
18
|
+
@controller.assert_loop_thread!(:session)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def respond_to_missing?(name, include_private = false)
|
|
23
|
+
__getobj__.respond_to?(name, include_private) || super
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
module Terminal
|
|
6
|
+
# Cursor emits the ANSI escape sequences for cursor visibility, screen clearing,
|
|
7
|
+
# and positioning. Replaces tty-cursor (a stateless escape-string generator) with
|
|
8
|
+
# the four sequences TTYBackend actually uses, asserted byte-for-byte in
|
|
9
|
+
# spec/internal/terminal/cursor_spec.rb.
|
|
10
|
+
module Cursor
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Shows the terminal cursor (DECTCEM set).
|
|
14
|
+
def show = "\e[?25h"
|
|
15
|
+
|
|
16
|
+
# Hides the terminal cursor (DECTCEM reset).
|
|
17
|
+
def hide = "\e[?25l"
|
|
18
|
+
|
|
19
|
+
# Clears the whole screen (ED 2).
|
|
20
|
+
def clear_screen = "\e[2J"
|
|
21
|
+
|
|
22
|
+
# Moves the cursor to zero-based *column*/*row* (CUP is one-based row;column).
|
|
23
|
+
def move_to(column, row)
|
|
24
|
+
"\e[#{row + 1};#{column + 1}H"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
module Terminal
|
|
6
|
+
# Size detects terminal dimensions without tty-screen: IO#winsize on the first
|
|
7
|
+
# reporting IO (a TTY), then ENV["COLUMNS"]/ENV["LINES"], then the 80x24 default.
|
|
8
|
+
module Size
|
|
9
|
+
DEFAULT_SIZE = [80, 24].freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Returns [width, height] for the first of *ios* that reports a winsize,
|
|
14
|
+
# falling back to the environment and then the default.
|
|
15
|
+
def measure(*ios, env: ENV)
|
|
16
|
+
ios.each do |io|
|
|
17
|
+
size = winsize(io)
|
|
18
|
+
return size if size
|
|
19
|
+
end
|
|
20
|
+
env_size(env) || DEFAULT_SIZE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# IO#winsize reports [rows, columns]; size is [width, height]. Nil for IOs
|
|
24
|
+
# without a size (StringIO, pipes, closed streams).
|
|
25
|
+
def winsize(io)
|
|
26
|
+
return nil unless io.respond_to?(:winsize)
|
|
27
|
+
|
|
28
|
+
rows, columns = io.winsize
|
|
29
|
+
return nil if rows.to_i.zero? || columns.to_i.zero?
|
|
30
|
+
|
|
31
|
+
[columns, rows]
|
|
32
|
+
rescue SystemCallError, IOError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The COLUMNS/LINES environment size, or nil when either is unset or zero.
|
|
37
|
+
def env_size(env)
|
|
38
|
+
columns = env["COLUMNS"].to_i
|
|
39
|
+
lines = env["LINES"].to_i
|
|
40
|
+
return nil if columns.zero? || lines.zero?
|
|
41
|
+
|
|
42
|
+
[columns, lines]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "io/console"
|
|
4
4
|
require "tty-reader"
|
|
5
|
-
require "tty-screen"
|
|
6
5
|
|
|
7
6
|
module Charming
|
|
8
7
|
module Internal
|
|
9
8
|
module Terminal
|
|
10
9
|
# TTYBackend is the production terminal backend. It reads key and mouse events from
|
|
11
10
|
# a TTY::Reader, normalizes them via KeyNormalizer and MouseParser, and writes output
|
|
12
|
-
# frames using
|
|
13
|
-
# handlers so the runtime can react to terminal resize
|
|
11
|
+
# frames using Terminal::Cursor sequences and IO#winsize for dimensions. It also
|
|
12
|
+
# installs SIGWINCH and SIGINFO handlers so the runtime can react to terminal resize
|
|
13
|
+
# and focus changes.
|
|
14
14
|
class TTYBackend
|
|
15
15
|
include Adapter
|
|
16
16
|
|
|
@@ -37,8 +37,9 @@ module Charming
|
|
|
37
37
|
|
|
38
38
|
# *input* and *output* default to `$stdin`/`$stdout` for normal terminal use;
|
|
39
39
|
# tests can inject IO objects. *reader* is a TTY::Reader instance (created from
|
|
40
|
-
# *input*/*output* when nil). *cursor* is the
|
|
41
|
-
|
|
40
|
+
# *input*/*output* when nil). *cursor* is the Terminal::Cursor module used for
|
|
41
|
+
# cursor control sequences.
|
|
42
|
+
def initialize(input: $stdin, output: $stdout, reader: nil, cursor: Cursor)
|
|
42
43
|
@input = input
|
|
43
44
|
@output = output
|
|
44
45
|
@reader = reader || TTY::Reader.new(input: input, output: output)
|
|
@@ -274,8 +275,9 @@ module Charming
|
|
|
274
275
|
write_control(@cursor.move_to(column - 1, row - 1))
|
|
275
276
|
end
|
|
276
277
|
|
|
277
|
-
# Returns the current terminal dimensions as [width, height]
|
|
278
|
-
|
|
278
|
+
# Returns the current terminal dimensions as [width, height]: IO#winsize on the
|
|
279
|
+
# output/input streams, then COLUMNS/LINES, then 80x24.
|
|
280
|
+
def size = Size.measure(@output, @input)
|
|
279
281
|
|
|
280
282
|
private
|
|
281
283
|
|
|
@@ -9,8 +9,8 @@ module Charming
|
|
|
9
9
|
#
|
|
10
10
|
# Autocomplete.new(suggestions: ["ruby", "rails", "rspec"], value: "r")
|
|
11
11
|
#
|
|
12
|
-
# `handle_key` returns `
|
|
13
|
-
#
|
|
12
|
+
# `handle_key` returns `Result.submitted(value)` on Enter, `Result.cancelled` on
|
|
13
|
+
# Escape, `Result.handled` for consumed keys, nil otherwise.
|
|
14
14
|
class Autocomplete < Component
|
|
15
15
|
DEFAULT_MAX_SUGGESTIONS = 6
|
|
16
16
|
|
|
@@ -29,6 +29,14 @@ module Charming
|
|
|
29
29
|
clamp_selection
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Replaces the suggestion list and reclamps the selection against the filtered
|
|
33
|
+
# list. Lets a memoized autocomplete stay fresh: keep the component in a slot
|
|
34
|
+
# and assign `combo.suggestions = names` before each render.
|
|
35
|
+
def suggestions=(new_suggestions)
|
|
36
|
+
@suggestions = Array(new_suggestions).map(&:to_s)
|
|
37
|
+
clamp_selection
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
# The typed text.
|
|
33
41
|
def value
|
|
34
42
|
@input.value
|
|
@@ -56,8 +64,8 @@ module Charming
|
|
|
56
64
|
# edits the text (resetting the highlight).
|
|
57
65
|
def handle_key(event)
|
|
58
66
|
case Charming.key_of(event)
|
|
59
|
-
when :escape then
|
|
60
|
-
when :enter then
|
|
67
|
+
when :escape then Result.cancelled
|
|
68
|
+
when :enter then Result.submitted(submission_value)
|
|
61
69
|
when :up then move_selection(-1)
|
|
62
70
|
when :down then move_selection(+1)
|
|
63
71
|
else
|
|
@@ -100,10 +108,10 @@ module Charming
|
|
|
100
108
|
|
|
101
109
|
def move_selection(delta)
|
|
102
110
|
count = filtered_suggestions.length
|
|
103
|
-
return
|
|
111
|
+
return Result.handled if count.zero?
|
|
104
112
|
|
|
105
113
|
@selected_index = (selected_index + delta).clamp(0, count - 1)
|
|
106
|
-
|
|
114
|
+
Result.handled
|
|
107
115
|
end
|
|
108
116
|
|
|
109
117
|
def clamp_selection
|
|
@@ -5,7 +5,8 @@ module Charming
|
|
|
5
5
|
# CommandPalette renders a fuzzy-searchable command picker UI. It wraps a TextInput for search
|
|
6
6
|
# input and a List for result display, dispatching key events between them. Users type to filter
|
|
7
7
|
# the registered commands by label match, navigate with up/down/home/end keys (delegated to List),
|
|
8
|
-
# confirm a selection with Enter (returns
|
|
8
|
+
# confirm a selection with Enter (returns Result.selected(command)), or cancel with Escape (returns
|
|
9
|
+
# Result.cancelled).
|
|
9
10
|
# State is serializable as a hash of value/cursor/selected_index for session persistence.
|
|
10
11
|
class CommandPalette < Component
|
|
11
12
|
Command = Data.define(:label, :value)
|
|
@@ -49,14 +50,14 @@ module Charming
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
# Handles key events by routing them to the appropriate sub-component: Escape kills the
|
|
52
|
-
# palette returning
|
|
53
|
+
# palette returning Result.cancelled; up/down/home/end keys go to the List selection handler
|
|
53
54
|
# via handle_list_key; all other keys (including typed characters) are passed to the TextInput
|
|
54
55
|
# which manages cursor position and input filtering. If a list key match fails, falls through
|
|
55
|
-
# to the TextInput handler. Returns nil
|
|
56
|
-
# Escape is pressed.
|
|
56
|
+
# to the TextInput handler. Returns nil if no handler consumed the event, or Result.cancelled
|
|
57
|
+
# when Escape is pressed.
|
|
57
58
|
def handle_key(event)
|
|
58
59
|
key = Charming.key_of(event)
|
|
59
|
-
return
|
|
60
|
+
return Result.cancelled if key == :escape
|
|
60
61
|
|
|
61
62
|
return handle_list_key(event) if list_key?(key)
|
|
62
63
|
|
|
@@ -75,17 +76,18 @@ module Charming
|
|
|
75
76
|
attr_reader :height, :list
|
|
76
77
|
|
|
77
78
|
# Delegates key handling entirely to the internal List widget, which manages up/down/home/end selection.
|
|
78
|
-
# Returns whatever the List's handle_key returns (
|
|
79
|
+
# Returns whatever the List's handle_key returns (a Result or nil).
|
|
79
80
|
def handle_list_key(event)
|
|
80
81
|
list.handle_key(event)
|
|
81
82
|
end
|
|
82
83
|
|
|
83
84
|
# Passes the key event to the TextInput for cursor position and search text management.
|
|
84
|
-
# If the input returns
|
|
85
|
-
# the new input value. Returns nil
|
|
85
|
+
# If the input returns Result.handled, rebuilds the List so that filtering is re-evaluated
|
|
86
|
+
# against the new input value. Returns the input's Result, or nil when it did not consume
|
|
87
|
+
# the event.
|
|
86
88
|
def handle_input_key(event)
|
|
87
89
|
result = input.handle_key(event)
|
|
88
|
-
@list = build_list if result
|
|
90
|
+
@list = build_list if result&.handled?
|
|
89
91
|
result
|
|
90
92
|
end
|
|
91
93
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Components
|
|
5
5
|
# Filepicker is a directory browser built on List. Enter descends into the
|
|
6
|
-
# highlighted directory or returns `
|
|
6
|
+
# highlighted directory or returns `Result.selected(absolute_path)` for a file;
|
|
7
7
|
# Backspace (or the "../" entry) goes up, never above the configured root.
|
|
8
8
|
# Dotfiles are hidden until `toggle_hidden`.
|
|
9
9
|
class Filepicker < Component
|
|
@@ -63,14 +63,14 @@ module Charming
|
|
|
63
63
|
return ascend if entry == PARENT_ENTRY
|
|
64
64
|
return descend(entry.delete_suffix("/")) if entry.end_with?("/")
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
Result.selected(File.join(current_dir, entry))
|
|
67
67
|
end
|
|
68
68
|
|
|
69
69
|
# Enters *name* under the current directory.
|
|
70
70
|
def descend(name)
|
|
71
71
|
@current_dir = File.join(current_dir, name)
|
|
72
72
|
rebuild_list
|
|
73
|
-
|
|
73
|
+
Result.handled
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# Moves to the parent directory, unless already at the root.
|
|
@@ -79,7 +79,7 @@ module Charming
|
|
|
79
79
|
|
|
80
80
|
@current_dir = File.dirname(current_dir)
|
|
81
81
|
rebuild_list
|
|
82
|
-
|
|
82
|
+
Result.handled
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# Builds a fresh List over the current directory's entries.
|
|
@@ -16,6 +16,7 @@ module Charming
|
|
|
16
16
|
|
|
17
17
|
# Handles the standard confirm keys: space toggles, y/right sets to true, n/left
|
|
18
18
|
# sets to false, and a space character (when the event exposes `char`) also toggles.
|
|
19
|
+
# Returns Result.handled when consumed, nil otherwise.
|
|
19
20
|
def handle_key(event)
|
|
20
21
|
case Charming.key_of(event)
|
|
21
22
|
when :space
|
|
@@ -29,7 +30,7 @@ module Charming
|
|
|
29
30
|
|
|
30
31
|
toggle
|
|
31
32
|
end
|
|
32
|
-
|
|
33
|
+
Result.handled
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# Returns ["must be accepted"] when required and the value is not true, otherwise
|
|
@@ -25,7 +25,7 @@ module Charming
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# Forwards key events to the underlying TextInput, syncing the value and cursor
|
|
28
|
-
# back into the form state. Returns
|
|
28
|
+
# back into the form state. Returns Result.handled when the event was consumed.
|
|
29
29
|
def handle_key(event)
|
|
30
30
|
forward_to_input(:handle_key, event)
|
|
31
31
|
end
|
|
@@ -41,11 +41,11 @@ module Charming
|
|
|
41
41
|
# the event, persists the resulting value and cursor into the form state.
|
|
42
42
|
def forward_to_input(message, event)
|
|
43
43
|
text_input = input
|
|
44
|
-
return nil unless text_input.public_send(message, event)
|
|
44
|
+
return nil unless text_input.public_send(message, event)&.handled?
|
|
45
45
|
|
|
46
46
|
state[:values][name] = text_input.value
|
|
47
47
|
field_state[:cursor] = text_input.cursor
|
|
48
|
-
|
|
48
|
+
Result.handled
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
@@ -27,16 +27,15 @@ module Charming
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Forwards key events to the underlying MultiSelectList, syncing the checked
|
|
30
|
-
# set and highlight cursor back into the field state. Returns
|
|
31
|
-
# consumed; Enter (the list's
|
|
30
|
+
# set and highlight cursor back into the field state. Returns Result.handled when
|
|
31
|
+
# consumed; Enter (the list's Result.submitted) is left unconsumed for the Form.
|
|
32
32
|
def handle_key(event)
|
|
33
33
|
widget = list
|
|
34
34
|
result = widget.handle_key(event)
|
|
35
|
-
return nil
|
|
36
|
-
return nil unless result == :handled
|
|
35
|
+
return nil unless result&.handled?
|
|
37
36
|
|
|
38
37
|
save_selection(widget)
|
|
39
|
-
|
|
38
|
+
Result.handled
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
private
|
|
@@ -24,14 +24,14 @@ module Charming
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
# Forwards key events to the underlying List, syncing the chosen option index back
|
|
27
|
-
# into the field state. Returns
|
|
27
|
+
# into the field state. Returns Result.handled when consumed.
|
|
28
28
|
def handle_key(event)
|
|
29
29
|
selection = list
|
|
30
30
|
result = selection.handle_key(event)
|
|
31
|
-
return nil unless result
|
|
31
|
+
return nil unless result&.handled?
|
|
32
32
|
|
|
33
33
|
save_selection(selection.selected_index)
|
|
34
|
-
|
|
34
|
+
Result.handled
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
private
|
|
@@ -27,7 +27,7 @@ module Charming
|
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
# Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
|
|
30
|
-
# and preferred column back into the form state. Returns
|
|
30
|
+
# and preferred column back into the form state. Returns Result.handled when consumed.
|
|
31
31
|
def handle_key(event)
|
|
32
32
|
forward_to_text_area(:handle_key, event)
|
|
33
33
|
end
|
|
@@ -51,13 +51,13 @@ module Charming
|
|
|
51
51
|
# event, persists the value, cursor, offset, and preferred column into form state.
|
|
52
52
|
def forward_to_text_area(message, event)
|
|
53
53
|
area = text_area
|
|
54
|
-
return nil unless area.public_send(message, event)
|
|
54
|
+
return nil unless area.public_send(message, event)&.handled?
|
|
55
55
|
|
|
56
56
|
state[:values][name] = area.value
|
|
57
57
|
field_state[:cursor] = area.cursor
|
|
58
58
|
field_state[:offset] = area.offset
|
|
59
59
|
field_state[:preferred_column] = area.preferred_column
|
|
60
|
-
|
|
60
|
+
Result.handled
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
@@ -28,7 +28,7 @@ module Charming
|
|
|
28
28
|
# or submits, and unhandled keys are passed to the focused field.
|
|
29
29
|
def handle_key(event)
|
|
30
30
|
key = Charming.key_of(event)
|
|
31
|
-
return
|
|
31
|
+
return Result.cancelled if key == :escape
|
|
32
32
|
return submit if submit_shortcut?(event)
|
|
33
33
|
return move_focus(tab_direction(event)) if key == :tab
|
|
34
34
|
|
|
@@ -103,14 +103,14 @@ module Charming
|
|
|
103
103
|
move_focus(+1)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
# Validates all fields, focuses the first invalid one, and returns
|
|
107
|
-
# when there are no errors.
|
|
106
|
+
# Validates all fields, focuses the first invalid one, and returns
|
|
107
|
+
# Result.submitted(values) when there are no errors.
|
|
108
108
|
def submit
|
|
109
109
|
state[:errors] = validation_errors
|
|
110
110
|
focus_first_error unless state[:errors].empty?
|
|
111
|
-
return
|
|
111
|
+
return Result.handled unless state[:errors].empty?
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
Result.submitted(values.dup)
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
# Runs each field's validator and collects per-field error messages.
|
|
@@ -139,7 +139,7 @@ module Charming
|
|
|
139
139
|
|
|
140
140
|
current = indices.index(state[:focus_index]) || 0
|
|
141
141
|
state[:focus_index] = indices[(current + direction) % indices.length]
|
|
142
|
-
|
|
142
|
+
Result.handled
|
|
143
143
|
end
|
|
144
144
|
|
|
145
145
|
# True when the current focus index is the last focusable field.
|
|
@@ -11,7 +11,7 @@ module Charming
|
|
|
11
11
|
#
|
|
12
12
|
# HelpOverlay.new(bindings: {"q" => "Quit", "ctrl+p" => "Command palette"})
|
|
13
13
|
#
|
|
14
|
-
# Any key dismisses it (`handle_key` returns
|
|
14
|
+
# Any key dismisses it (`handle_key` returns Result.cancelled).
|
|
15
15
|
class HelpOverlay < Component
|
|
16
16
|
DEFAULT_TITLE = "Keyboard Shortcuts"
|
|
17
17
|
DEFAULT_WIDTH = 44
|
|
@@ -40,7 +40,7 @@ module Charming
|
|
|
40
40
|
|
|
41
41
|
# Any key dismisses the overlay.
|
|
42
42
|
def handle_key(_event)
|
|
43
|
-
|
|
43
|
+
Result.cancelled
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# Renders the bindings table inside a titled modal.
|
|
@@ -6,8 +6,8 @@ module Charming
|
|
|
6
6
|
# to private method calls. Implementors must define a constant +KEY_ACTIONS+ as a hash where each key is
|
|
7
7
|
# a symbol (e.g., :up, :down, :enter) and each value is the target method name (e.g., :move_up). Call
|
|
8
8
|
# +handle_key(event)+ with any event object; it uses Charming.key_of to resolve the raw event to a symbol,
|
|
9
|
-
# looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns
|
|
10
|
-
# action was found. Returns nil
|
|
9
|
+
# looks up the corresponding action in KEY_ACTIONS, sends that method on self, and returns Result.handled
|
|
10
|
+
# if an action was found. Returns nil when no matching key exists.
|
|
11
11
|
module KeyboardHandler
|
|
12
12
|
VIM_KEYMAP = {
|
|
13
13
|
up: :k,
|
|
@@ -22,7 +22,7 @@ module Charming
|
|
|
22
22
|
return unless action
|
|
23
23
|
|
|
24
24
|
send(action)
|
|
25
|
-
|
|
25
|
+
Result.handled
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
private
|