charming 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +421 -0
- data/exe/charming +6 -0
- data/lib/charming/application.rb +90 -0
- data/lib/charming/application_model.rb +13 -0
- data/lib/charming/cli.rb +60 -0
- data/lib/charming/component.rb +8 -0
- data/lib/charming/components/activity_indicator.rb +158 -0
- data/lib/charming/components/command_palette.rb +118 -0
- data/lib/charming/components/keyboard_handler.rb +22 -0
- data/lib/charming/components/list.rb +105 -0
- data/lib/charming/components/modal.rb +48 -0
- data/lib/charming/components/progressbar.rb +55 -0
- data/lib/charming/components/spinner.rb +37 -0
- data/lib/charming/components/table.rb +115 -0
- data/lib/charming/components/text_input.rb +103 -0
- data/lib/charming/components/viewport.rb +191 -0
- data/lib/charming/controller.rb +523 -0
- data/lib/charming/focus.rb +65 -0
- data/lib/charming/generators/app_file_generator.rb +28 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
- data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
- data/lib/charming/generators/app_generator/component_templates.rb +36 -0
- data/lib/charming/generators/app_generator/controller_template.rb +69 -0
- data/lib/charming/generators/app_generator/layout_template.rb +160 -0
- data/lib/charming/generators/app_generator/model_templates.rb +30 -0
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
- data/lib/charming/generators/app_generator/view_template.rb +90 -0
- data/lib/charming/generators/app_generator.rb +76 -0
- data/lib/charming/generators/base.rb +29 -0
- data/lib/charming/generators/component_generator.rb +30 -0
- data/lib/charming/generators/controller_generator.rb +50 -0
- data/lib/charming/generators/name.rb +32 -0
- data/lib/charming/generators/screen_generator.rb +154 -0
- data/lib/charming/generators/view_generator.rb +34 -0
- data/lib/charming/generators.rb +7 -0
- data/lib/charming/internal/renderer/differential.rb +53 -0
- data/lib/charming/internal/renderer/full_repaint.rb +19 -0
- data/lib/charming/internal/terminal/adapter.rb +52 -0
- data/lib/charming/internal/terminal/memory_backend.rb +91 -0
- data/lib/charming/internal/terminal/tty_backend.rb +250 -0
- data/lib/charming/key_event.rb +13 -0
- data/lib/charming/mouse_event.rb +40 -0
- data/lib/charming/resize_event.rb +7 -0
- data/lib/charming/response.rb +33 -0
- data/lib/charming/router.rb +137 -0
- data/lib/charming/runtime.rb +192 -0
- data/lib/charming/screen.rb +8 -0
- data/lib/charming/task.rb +7 -0
- data/lib/charming/task_event.rb +17 -0
- data/lib/charming/task_executor.rb +62 -0
- data/lib/charming/timer_event.rb +7 -0
- data/lib/charming/ui/border.rb +33 -0
- data/lib/charming/ui/style.rb +244 -0
- data/lib/charming/ui/theme.rb +178 -0
- data/lib/charming/ui/themes/phosphor.json +100 -0
- data/lib/charming/ui/width.rb +24 -0
- data/lib/charming/ui.rb +230 -0
- data/lib/charming/version.rb +5 -0
- data/lib/charming/view.rb +116 -0
- data/lib/charming.rb +24 -0
- data/sig/charming.rbs +3 -0
- metadata +225 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# MOUSE_BUTTON_MAP encodes terminal mouse button codes to semantic symbols. The constant is frozen and private.
|
|
5
|
+
MOUSE_BUTTON_MAP = {
|
|
6
|
+
0 => :left, 1 => :middle, 2 => :right, 3 => :release,
|
|
7
|
+
64 => :scroll_up, 65 => :scroll_down,
|
|
8
|
+
66 => :scroll_up, 67 => :scroll_down
|
|
9
|
+
}.freeze
|
|
10
|
+
private_constant :MOUSE_BUTTON_MAP
|
|
11
|
+
|
|
12
|
+
# MouseEvent represents a mouse input event. *button* encodes which button or action was triggered (left,
|
|
13
|
+
# right, scroll), while *x* and *y* provide the cursor position. Modifier booleans (*ctrl*, *alt*, *shift*)
|
|
14
|
+
# capture key state at the time of the event.
|
|
15
|
+
MouseEvent = Data.define(:button, :x, :y, :ctrl, :alt, :shift) do
|
|
16
|
+
def initialize(button:, x:, y:, ctrl: false, alt: false, shift: false)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the semantic symbol for *button* — one of `left`, `right`, `scroll_up`, etc. or `:unknown`.
|
|
21
|
+
def button_name
|
|
22
|
+
MOUSE_BUTTON_MAP.fetch(button, :unknown)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns `true` when the current event is a click (left, middle, or right button).
|
|
26
|
+
def click?
|
|
27
|
+
%i[left middle right].include?(button_name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns `true` when the button name maps to either direction of scroll.
|
|
31
|
+
def scroll?
|
|
32
|
+
%i[scroll_up scroll_down].include?(button_name)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns `true` when the current event is a mouse release action.
|
|
36
|
+
def release?
|
|
37
|
+
button_name == :release
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# ResizeEvent represents a terminal window resize. *width* and *height* carry the new terminal dimensions
|
|
5
|
+
# in screen cells, replacing the previous Screen dimensions for all subsequent rendering.
|
|
6
|
+
ResizeEvent = Data.define(:width, :height)
|
|
7
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Response encapsulates a controller's dispatch outcome — one of render text, navigate to another route, or quit.
|
|
5
|
+
# Rails-style factories (`render`, `navigate`, `quit`) serve as the public API and map to :kind values
|
|
6
|
+
# that the Runtime interprets at the end of each event loop iteration.
|
|
7
|
+
Response = Data.define(:kind, :body, :path) do
|
|
8
|
+
# Factory constructing a Render response for displaying *body* text on the current screen.
|
|
9
|
+
def self.render(body)
|
|
10
|
+
new(kind: :render, body: body, path: nil)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Factory constructing a NavigateResponse routing to the named *path* (string).
|
|
14
|
+
def self.navigate(path)
|
|
15
|
+
new(kind: :navigate, body: "", path: path)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Factory constructing a QuitResponse signalling termination of the top-level event loop.
|
|
19
|
+
def self.quit
|
|
20
|
+
new(kind: :quit, body: "", path: nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Returns `true` when this response is navigating to another screen or route.
|
|
24
|
+
def navigate?
|
|
25
|
+
kind == :navigate
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns `true` when this response requests quitting the application.
|
|
29
|
+
def quit?
|
|
30
|
+
kind == :quit
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
# Router manages an application's route table and provides a Rails-inspired DSL for defining routes.
|
|
7
|
+
# Each route maps a URL path to a controller, action (implicitly :show), and title (for sidebar display).
|
|
8
|
+
class Router
|
|
9
|
+
# Route is a Data object holding a route's path template, target controller/action, title, and resolved params.
|
|
10
|
+
Route = Data.define(:path, :controller_class, :action, :title, :params) do
|
|
11
|
+
def with_params(params)
|
|
12
|
+
self.class.new(
|
|
13
|
+
path: path,
|
|
14
|
+
controller_class: controller_class,
|
|
15
|
+
action: action,
|
|
16
|
+
title: title,
|
|
17
|
+
params: params
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
DynamicRoute = Data.define(:route, :pattern, :param_names)
|
|
23
|
+
|
|
24
|
+
# Initializes a new router with an optional namespace prefix for controller constant lookups.
|
|
25
|
+
def initialize(namespace: nil)
|
|
26
|
+
@namespace = namespace
|
|
27
|
+
@routes = {}
|
|
28
|
+
@dynamic_routes = []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Evaluates a block in the context of this Router instance using instance_eval, allowing DSL calls like screen and root to register routes.
|
|
32
|
+
# This is how `routes.draw { screen "/", to: "HomeController", title: "Home" }` works.
|
|
33
|
+
def draw(&)
|
|
34
|
+
instance_eval(&)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Registers the home screen at "/" with a given title. Shorthand for `screen path, to: target`.
|
|
38
|
+
# Example: `root "HomeController"` maps `/` → HomeController#show with title "Home".
|
|
39
|
+
def root(target, title: "Home")
|
|
40
|
+
screen("/", to: target, title: title)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Maps a URL path to a controller and action (e.g. "HomeController" for HomeController#show).
|
|
44
|
+
# Builds a Route object from the path, resolved controller constant, parsed action, and an optional or derived title.
|
|
45
|
+
def screen(path, to:, title: nil)
|
|
46
|
+
controller_name, action = to.split("#", 2)
|
|
47
|
+
route = Route.new(
|
|
48
|
+
path: path,
|
|
49
|
+
controller_class: constantize(controller_constant_name(controller_name)),
|
|
50
|
+
action: action.to_sym,
|
|
51
|
+
title: title || derive_title(path),
|
|
52
|
+
params: {}
|
|
53
|
+
)
|
|
54
|
+
@routes[path] = route
|
|
55
|
+
@dynamic_routes.reject! { |dynamic_route| dynamic_route.route.path == path }
|
|
56
|
+
@dynamic_routes << compile_dynamic_route(route) if dynamic_path?(path)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Resolves a route by path from the router's table. Exact routes win over dynamic routes.
|
|
60
|
+
# Raises KeyError if no route matches.
|
|
61
|
+
# Used at runtime to look up the controller class and action for incoming requests.
|
|
62
|
+
def resolve(path = "/")
|
|
63
|
+
@routes[path] || resolve_dynamic(path) || raise(KeyError, "key not found: #{path.inspect}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Returns all registered routes as Route objects, ordered by insertion.
|
|
67
|
+
# Consumed by the application loop to populate the sidebar and by controllers for navigation context.
|
|
68
|
+
def all
|
|
69
|
+
@routes.values
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The namespace prefix from initialization — used to scope controller constant lookups.
|
|
75
|
+
# For example, namespace "Admin" means HomeController resolves as Admin::HomeController.
|
|
76
|
+
attr_reader :namespace
|
|
77
|
+
|
|
78
|
+
def dynamic_path?(path)
|
|
79
|
+
path.split("/").any? { |segment| segment.start_with?(":") && segment.length > 1 }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compile_dynamic_route(route)
|
|
83
|
+
param_names = []
|
|
84
|
+
segments = route.path.split("/", -1).map do |segment|
|
|
85
|
+
if segment.start_with?(":") && segment.length > 1
|
|
86
|
+
param_names << segment.delete_prefix(":").to_sym
|
|
87
|
+
"([^/]+)"
|
|
88
|
+
else
|
|
89
|
+
Regexp.escape(segment)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
DynamicRoute.new(route: route, pattern: /\A#{segments.join("/")}\z/, param_names: param_names)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_dynamic(path)
|
|
97
|
+
@dynamic_routes.each do |dynamic_route|
|
|
98
|
+
match = dynamic_route.pattern.match(path)
|
|
99
|
+
return dynamic_route.route.with_params(extract_params(dynamic_route.param_names, match.captures)) if match
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def extract_params(names, values)
|
|
106
|
+
names.zip(values).to_h do |name, value|
|
|
107
|
+
[name, URI.decode_www_form_component(value)]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Splits a camel-case string into words for title derivation (e.g., "my_route" → ["my", "route"]).
|
|
112
|
+
def camelize(value)
|
|
113
|
+
value.split("_").map(&:capitalize).join
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Looks up a constant by name in Object. Used to resolve controller strings from route definitions.
|
|
117
|
+
def constantize(name)
|
|
118
|
+
Object.const_get(name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Builds the full controller constant name, prepending the namespace if present.
|
|
122
|
+
# For example: "HomeController" with namespace "Admin" → "Admin::HomeController".
|
|
123
|
+
def controller_constant_name(controller_name)
|
|
124
|
+
name = "#{camelize(controller_name)}Controller"
|
|
125
|
+
@namespace.to_s.empty? ? name : "#{@namespace}::#{name}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Derives a human-readable title from a URL path by stripping the leading slash,
|
|
129
|
+
# splitting on underscores/hyphens/slashes, capitalizing each segment, and joining with spaces.
|
|
130
|
+
# Examples: "/projects" → "Projects", "/projects/list" → "Projects List".
|
|
131
|
+
def derive_title(path)
|
|
132
|
+
return "Home" if path == "/"
|
|
133
|
+
|
|
134
|
+
path.delete_prefix("/").split(%r{[_\-/]}).map(&:capitalize).join(" ")
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Runtime manages a terminal UI application's lifecycle: setting up an
|
|
5
|
+
# alternative-screen terminal with cursor hiding, running an event loop that
|
|
6
|
+
# reads keyboard, mouse, timer, and task events, dispatching them to
|
|
7
|
+
# controllers, rendering responses, and tearing down cleanly on exit.
|
|
8
|
+
class Runtime
|
|
9
|
+
DEFAULT_READ_TIMEOUT = 0.05
|
|
10
|
+
|
|
11
|
+
def initialize(application, backend: nil, renderer: nil, clock: nil, task_executor: nil)
|
|
12
|
+
@application = application
|
|
13
|
+
@backend = backend || Internal::Terminal::TTYBackend.new
|
|
14
|
+
@renderer = renderer || Internal::Renderer::Differential.new(@backend)
|
|
15
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
16
|
+
@task_queue = Thread::Queue.new
|
|
17
|
+
@task_executor = build_task_executor(task_executor)
|
|
18
|
+
@application.task_executor = @task_executor
|
|
19
|
+
@route = @application.routes.resolve("/")
|
|
20
|
+
@screen = backend_screen
|
|
21
|
+
@timers = build_timers
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Runs the event loop: enters alt-screen, dispatches incoming events
|
|
25
|
+
# (key, mouse, timer, async task), renders controller responses, and
|
|
26
|
+
# restores terminal state on exit.
|
|
27
|
+
def run
|
|
28
|
+
setup_terminal
|
|
29
|
+
render(resolve_response(dispatch(@route.action)))
|
|
30
|
+
loop do
|
|
31
|
+
event = next_task_event || next_timer_event || @backend.read_event(timeout: read_timeout)
|
|
32
|
+
next unless event
|
|
33
|
+
|
|
34
|
+
response = dispatch_event(event)
|
|
35
|
+
next unless response
|
|
36
|
+
break if response.quit?
|
|
37
|
+
|
|
38
|
+
response = resolve_response(response)
|
|
39
|
+
break if response.quit?
|
|
40
|
+
|
|
41
|
+
render(response)
|
|
42
|
+
end
|
|
43
|
+
ensure
|
|
44
|
+
@task_executor&.shutdown(timeout: 0.0)
|
|
45
|
+
restore_terminal
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :screen
|
|
51
|
+
|
|
52
|
+
# Dispatches an action on the current route's controller with an optional event.
|
|
53
|
+
# Entry point from the event loop into controllers.
|
|
54
|
+
def dispatch(action, event: nil)
|
|
55
|
+
controller(event: event).dispatch(action)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Dispatches a key press to the current route's controller.
|
|
59
|
+
def dispatch_key(event)
|
|
60
|
+
controller(event: event).dispatch_key
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Dispatches a timer tick to the current route's controller.
|
|
64
|
+
def dispatch_timer(event)
|
|
65
|
+
controller(event: event).dispatch_timer
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Dispatches an async task result to the current route's controller.
|
|
69
|
+
def dispatch_task(event)
|
|
70
|
+
controller(event: event).dispatch_task
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Dispatches a mouse action (click, drag, scroll) to the current route's controller.
|
|
74
|
+
def dispatch_mouse(event)
|
|
75
|
+
controller(event: event).dispatch_mouse
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def controller(event: nil)
|
|
79
|
+
@route.controller_class.new(application: @application, event: event, params: @route.params, screen: screen)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Type-based dispatcher: routes resize, task, timer, mouse, and key events
|
|
83
|
+
# to the appropriate handler. Falls back to key dispatch for unclassified events.
|
|
84
|
+
def dispatch_event(event)
|
|
85
|
+
return dispatch_resize(event) if event.is_a?(ResizeEvent)
|
|
86
|
+
return dispatch_task(event) if event.is_a?(TaskEvent)
|
|
87
|
+
return dispatch_timer(event) if event.is_a?(TimerEvent)
|
|
88
|
+
return dispatch_mouse(event) if event.is_a?(MouseEvent)
|
|
89
|
+
|
|
90
|
+
dispatch_key(event)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Dispatches a resize event: updates screen dimensions and re-renders the current action.
|
|
94
|
+
def dispatch_resize(event)
|
|
95
|
+
@screen = Screen.new(width: event.width, height: event.height)
|
|
96
|
+
dispatch(@route.action, event: event)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Follows navigation responses: resolves the new route from the router,
|
|
100
|
+
# resets timers for the new route, and dispatches that route's action.
|
|
101
|
+
def resolve_response(response)
|
|
102
|
+
return response unless response.navigate?
|
|
103
|
+
|
|
104
|
+
@route = @application.routes.resolve(response.path)
|
|
105
|
+
@timers = build_timers
|
|
106
|
+
dispatch(@route.action)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Derives Screen dimensions (width, height) from the terminal backend.
|
|
110
|
+
def backend_screen
|
|
111
|
+
width, height = @backend.size
|
|
112
|
+
Screen.new(width: width, height: height)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Renders the body portion of a response through the renderer.
|
|
116
|
+
def render(response)
|
|
117
|
+
@renderer.render(response.body)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Builds the initial set of timer states from controller bindings and the current clock time.
|
|
121
|
+
def build_timers
|
|
122
|
+
now = clock_now
|
|
123
|
+
@route.controller_class.timer_bindings.values.map do |binding|
|
|
124
|
+
{binding: binding, next_at: now + binding.interval}
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns a TimerEvent for the first due timer and advances its next fire time.
|
|
129
|
+
# Returns nil if no timers are ready or registered.
|
|
130
|
+
def next_timer_event
|
|
131
|
+
timer = due_timer
|
|
132
|
+
return unless timer
|
|
133
|
+
|
|
134
|
+
now = clock_now
|
|
135
|
+
timer[:next_at] = now + timer.fetch(:binding).interval
|
|
136
|
+
TimerEvent.new(name: timer.fetch(:binding).name, now: now)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Pops a task event from the thread-safe queue if one is available.
|
|
140
|
+
# Non-blocking — returns nil immediately when the queue is empty.
|
|
141
|
+
def next_task_event
|
|
142
|
+
@task_queue.pop(true)
|
|
143
|
+
rescue ThreadError
|
|
144
|
+
nil
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns timer values due at or before `now`, sorted by next fire time.
|
|
148
|
+
def due_timer
|
|
149
|
+
now = clock_now
|
|
150
|
+
@timers.select { |timer| timer.fetch(:next_at) <= now }.min_by { |timer| timer.fetch(:next_at) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Computes how long to block waiting for input based on when the next timer is due,
|
|
154
|
+
# clamped between 0 and DEFAULT_READ_TIMEOUT (0.05s). Returns DEFAULT when no timers exist.
|
|
155
|
+
def read_timeout
|
|
156
|
+
next_at = @timers.map { |timer| timer.fetch(:next_at) }.min
|
|
157
|
+
return DEFAULT_READ_TIMEOUT unless next_at
|
|
158
|
+
|
|
159
|
+
(next_at - clock_now).clamp(0, DEFAULT_READ_TIMEOUT)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Constructs a task executor: supports explicit instances, callable factories, or the default Threaded executor.
|
|
163
|
+
def build_task_executor(task_executor)
|
|
164
|
+
return TaskExecutor::Threaded.new(@task_queue) unless task_executor
|
|
165
|
+
return task_executor if task_executor.respond_to?(:submit)
|
|
166
|
+
return task_executor.call(@task_queue) if task_executor.respond_to?(:call) && !task_executor.respond_to?(:new)
|
|
167
|
+
|
|
168
|
+
task_executor.new(@task_queue)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Returns the clock proc, providing a single point of access for time in the event loop.
|
|
172
|
+
def clock_now
|
|
173
|
+
@clock.call
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Enters an alternative screen buffer, hides the cursor, and installs
|
|
177
|
+
# a terminal resize signal handler if supported by the backend.
|
|
178
|
+
def setup_terminal
|
|
179
|
+
@backend.enter_alt_screen
|
|
180
|
+
@backend.hide_cursor
|
|
181
|
+
@backend.install_resize_handler if @backend.respond_to?(:install_resize_handler)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Restores terminal state: reinstalls any previous resize handler, shows
|
|
185
|
+
# the cursor, and leaves the alternative screen buffer.
|
|
186
|
+
def restore_terminal
|
|
187
|
+
@backend.restore_resize_handler if @backend.respond_to?(:restore_resize_handler)
|
|
188
|
+
@backend.show_cursor
|
|
189
|
+
@backend.leave_alt_screen
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Screen represents the terminal viewport dimensions as a simple Data class.
|
|
5
|
+
# The `width` and `height` values flow from the backend through the runtime
|
|
6
|
+
# loop into every controller dispatch for layout calculations.
|
|
7
|
+
Screen = Data.define(:width, :height)
|
|
8
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# TaskEvent represents background task completion. *name* is the declared task identifier, *value* carries
|
|
5
|
+
# the return result and *error* captures any exception raised during execution. The `error?` predicate
|
|
6
|
+
# simplifies error handling in controller handlers.
|
|
7
|
+
TaskEvent = Data.define(:name, :value, :error) do
|
|
8
|
+
def initialize(name:, value: nil, error: nil)
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Returns `true` when the task finished with a non-nil exception.
|
|
13
|
+
def error?
|
|
14
|
+
!error.nil?
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module TaskExecutor
|
|
5
|
+
class Threaded
|
|
6
|
+
def initialize(queue)
|
|
7
|
+
@queue = queue
|
|
8
|
+
@threads = []
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def submit(name, &block)
|
|
13
|
+
task = Task.new(name: name.to_sym, block: block)
|
|
14
|
+
thread = Thread.new(task) { |t| @queue << run(t) }
|
|
15
|
+
@mutex.synchronize { @threads << thread }
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def shutdown(timeout: 0.0)
|
|
20
|
+
threads = @mutex.synchronize { @threads.dup }
|
|
21
|
+
threads.each { |thread| thread.join(timeout) }
|
|
22
|
+
threads.each do |thread|
|
|
23
|
+
next unless thread.alive?
|
|
24
|
+
|
|
25
|
+
thread.kill
|
|
26
|
+
thread.join(0)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def run(task)
|
|
33
|
+
TaskEvent.new(name: task.name, value: task.call)
|
|
34
|
+
rescue StandardError, ScriptError => e
|
|
35
|
+
TaskEvent.new(name: task.name, error: e)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Inline
|
|
40
|
+
def initialize(queue)
|
|
41
|
+
@queue = queue
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def submit(name, &block)
|
|
45
|
+
task = Task.new(name: name.to_sym, block: block)
|
|
46
|
+
@queue << run(task)
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def shutdown(timeout: 0.0)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def run(task)
|
|
56
|
+
TaskEvent.new(name: task.name, value: task.call)
|
|
57
|
+
rescue StandardError, ScriptError => e
|
|
58
|
+
TaskEvent.new(name: task.name, error: e)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# TimerEvent represents a timed dispatch from the runtime loop. *name* is the declared timer identifier;
|
|
5
|
+
# *now* is the monotonically rising clock value at emission for throttle comparisons.
|
|
6
|
+
TimerEvent = Data.define(:name, :now)
|
|
7
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module UI
|
|
5
|
+
class Border
|
|
6
|
+
attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
|
|
7
|
+
|
|
8
|
+
def initialize(corners:, edges:)
|
|
9
|
+
@top_left, @top_right, @bottom_left, @bottom_right = corners
|
|
10
|
+
@horizontal, @vertical = edges
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.fetch(name)
|
|
14
|
+
STYLES.fetch(name.to_sym)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Border::STYLES = {
|
|
19
|
+
normal: Border.new(
|
|
20
|
+
corners: ["+", "+", "+", "+"], edges: ["-", "|"]
|
|
21
|
+
),
|
|
22
|
+
rounded: Border.new(
|
|
23
|
+
corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
|
|
24
|
+
),
|
|
25
|
+
thick: Border.new(
|
|
26
|
+
corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
|
|
27
|
+
),
|
|
28
|
+
double: Border.new(
|
|
29
|
+
corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
|
|
30
|
+
)
|
|
31
|
+
}.freeze
|
|
32
|
+
end
|
|
33
|
+
end
|