charming 0.2.3 → 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 +3 -1
- data/lib/charming/application.rb +98 -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 +79 -17
- 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/timers.rb +25 -0
- data/lib/charming/controller.rb +253 -60
- 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/event_loop.rb +25 -2
- 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/internal/timer_control.rb +48 -0
- 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/projectile.rb +64 -0
- 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 +54 -28
- 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/spring.rb +126 -0
- 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 +20 -6
- metadata +25 -70
- data/lib/charming/controller/component_dispatching.rb +0 -125
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Raised when a controller sets the response twice in one dispatch — render, navigate,
|
|
5
|
+
# and quit each assign the response, and a second assignment would silently discard the
|
|
6
|
+
# first. The message names the action, the response already set, and the one attempted.
|
|
7
|
+
class DoubleRenderError < Error; end
|
|
8
|
+
end
|
|
@@ -99,12 +99,15 @@ module Charming
|
|
|
99
99
|
|
|
100
100
|
def controller_chrome
|
|
101
101
|
class_body_block(<<~RUBY)
|
|
102
|
+
include Charming::Shell::Sidebar
|
|
103
|
+
include Charming::Shell::Palette
|
|
104
|
+
|
|
102
105
|
focus_ring :sidebar, :content
|
|
103
106
|
|
|
104
107
|
key "ctrl+p", :open_command_palette, scope: :global
|
|
105
108
|
|
|
106
109
|
command "Home" do
|
|
107
|
-
|
|
110
|
+
navigate :root
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
command "Theme", :open_theme_palette
|
|
@@ -60,7 +60,7 @@ module Charming
|
|
|
60
60
|
|
|
61
61
|
# The CamelCase migration class name derived from the snake_case migration name.
|
|
62
62
|
def migration_class_name
|
|
63
|
-
|
|
63
|
+
Internal::Inflections.camelize(name.snake_name)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
# Builds the `change` method body based on the migration name convention.
|
|
@@ -103,12 +103,12 @@ module Charming
|
|
|
103
103
|
|
|
104
104
|
# The pluralized table name (e.g., "user" → "users", "category" → "categories").
|
|
105
105
|
def table_name
|
|
106
|
-
|
|
106
|
+
Internal::Inflections.pluralize(name.snake_name)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
# The CamelCase migration class name (e.g., "users" → "Users").
|
|
110
110
|
def table_class_name
|
|
111
|
-
|
|
111
|
+
Internal::Inflections.camelize(table_name)
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
# A migration timestamp in ActiveRecord's filename format, bumped past any
|
|
@@ -21,7 +21,7 @@ module Charming
|
|
|
21
21
|
|
|
22
22
|
# The CamelCase class name (e.g., "user" → "User").
|
|
23
23
|
def class_name
|
|
24
|
-
|
|
24
|
+
Internal::Inflections.camelize(snake_name)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# The controller class name (e.g., "user" → "UserController").
|
|
@@ -141,14 +141,14 @@ module Charming
|
|
|
141
141
|
|
|
142
142
|
# Inserts a `screen` route into `config/routes.rb`, idempotently.
|
|
143
143
|
def insert_route
|
|
144
|
-
route = %( screen
|
|
144
|
+
route = %( screen :#{name.snake_name}, "#{name.snake_name}#show", title: "#{name.class_name}")
|
|
145
145
|
insert_before_end(route_path, route, "route", "end")
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# Inserts a `command` block into `ApplicationController`, idempotently.
|
|
149
149
|
def insert_command
|
|
150
150
|
command = %( command "#{name.class_name}" do
|
|
151
|
-
|
|
151
|
+
navigate :#{name.snake_name}
|
|
152
152
|
end)
|
|
153
153
|
insert_before_end(application_controller_path, command, "command", " end")
|
|
154
154
|
end
|
|
@@ -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
|
|
@@ -51,6 +51,24 @@ module Charming
|
|
|
51
51
|
@timers = build_timers(timer_bindings)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# Schedules *binding* one interval from now. Idempotent: starting a timer
|
|
55
|
+
# that is already running keeps its current deadline (no phase reset).
|
|
56
|
+
def start_timer(binding)
|
|
57
|
+
return if timer_running?(binding.name)
|
|
58
|
+
|
|
59
|
+
@timers << {binding: binding, next_at: clock_now + binding.interval}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Removes the named timer from the schedule. Idempotent for unknown names.
|
|
63
|
+
def stop_timer(name)
|
|
64
|
+
@timers.reject! { |timer| timer.fetch(:binding).name == name }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# True while the named timer is scheduled.
|
|
68
|
+
def timer_running?(name)
|
|
69
|
+
@timers.any? { |timer| timer.fetch(:binding).name == name }
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
private
|
|
55
73
|
|
|
56
74
|
# The next due event in priority order: task results, then timers, then input.
|
|
@@ -74,13 +92,18 @@ module Charming
|
|
|
74
92
|
end
|
|
75
93
|
|
|
76
94
|
# Returns a TimerEvent for the first due timer and advances its next fire
|
|
77
|
-
# time. Returns nil if no timers are ready or registered.
|
|
95
|
+
# time. Returns nil if no timers are ready or registered. Rescheduling is
|
|
96
|
+
# accumulator-based so fire times stay on the interval grid (no drift);
|
|
97
|
+
# after a stall the deadline snaps forward, skipping missed ticks instead
|
|
98
|
+
# of firing a catch-up burst.
|
|
78
99
|
def next_timer_event
|
|
79
100
|
timer = due_timer
|
|
80
101
|
return unless timer
|
|
81
102
|
|
|
82
103
|
now = clock_now
|
|
83
|
-
|
|
104
|
+
interval = timer.fetch(:binding).interval
|
|
105
|
+
timer[:next_at] += interval
|
|
106
|
+
timer[:next_at] = now + interval if timer[:next_at] <= now
|
|
84
107
|
Events::TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
85
108
|
end
|
|
86
109
|
|
|
@@ -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
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Internal
|
|
5
|
+
# TimerControl is the runtime-injected surface that lets ephemeral controllers
|
|
6
|
+
# start and stop the EventLoop's scheduled timers by name (the same pattern as
|
|
7
|
+
# Application#task_executor). *bindings* is a callable returning the current
|
|
8
|
+
# route's timer bindings, so navigation needs no re-wiring.
|
|
9
|
+
class TimerControl
|
|
10
|
+
def initialize(event_loop:, bindings:)
|
|
11
|
+
@event_loop = event_loop
|
|
12
|
+
@bindings = bindings
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Schedules the named declared timer on the event loop.
|
|
16
|
+
def start(name)
|
|
17
|
+
binding = @bindings.call[name.to_sym]
|
|
18
|
+
raise ArgumentError, "unknown timer #{name.to_sym.inspect}" unless binding
|
|
19
|
+
|
|
20
|
+
@event_loop.start_timer(binding)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Unschedules the named timer.
|
|
24
|
+
def stop(name)
|
|
25
|
+
@event_loop.stop_timer(name.to_sym)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# True while the named timer is scheduled.
|
|
29
|
+
def running?(name)
|
|
30
|
+
@event_loop.timer_running?(name.to_sym)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Null is the default control for applications running outside a Runtime
|
|
34
|
+
# (unit specs, console): starts and stops are no-ops.
|
|
35
|
+
class Null
|
|
36
|
+
def start(name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop(name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def running?(name)
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -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.
|