charming 0.2.2 → 0.3.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 +16 -3
- data/lib/charming/application.rb +7 -0
- data/lib/charming/cli.rb +16 -4
- data/lib/charming/controller/class_methods.rb +13 -2
- data/lib/charming/controller/timers.rb +25 -0
- data/lib/charming/controller.rb +7 -2
- data/lib/charming/generators/app_file_generator.rb +29 -0
- data/lib/charming/generators/app_generator.rb +12 -25
- data/lib/charming/generators/base.rb +5 -0
- data/lib/charming/generators/layout_generator.rb +155 -0
- data/lib/charming/generators/screen_generator.rb +0 -29
- data/lib/charming/generators/templates/app/README.md.template +15 -1
- data/lib/charming/generators/templates/app/application.template +0 -6
- data/lib/charming/generators/templates/app/application_controller.template +0 -10
- data/lib/charming/generators/templates/app/layout.template +4 -93
- data/lib/charming/generators/templates/app/routes.template +3 -1
- data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
- data/lib/charming/image/protocol/kitty.rb +7 -0
- data/lib/charming/image/source.rb +10 -0
- data/lib/charming/internal/event_loop.rb +25 -2
- data/lib/charming/internal/timer_control.rb +48 -0
- data/lib/charming/presentation/components/autocomplete.rb +7 -0
- data/lib/charming/presentation/components/form/field.rb +5 -0
- data/lib/charming/presentation/components/form/input.rb +14 -4
- data/lib/charming/presentation/components/form/textarea.rb +18 -8
- data/lib/charming/presentation/components/form.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +4 -4
- data/lib/charming/presentation/components/stopwatch.rb +1 -1
- data/lib/charming/presentation/components/text_area.rb +2 -2
- data/lib/charming/presentation/components/text_input.rb +2 -2
- data/lib/charming/presentation/components/timer.rb +1 -1
- data/lib/charming/presentation/components/toast.rb +4 -3
- data/lib/charming/presentation/components/viewport.rb +11 -1
- data/lib/charming/projectile.rb +64 -0
- data/lib/charming/runtime.rb +25 -4
- data/lib/charming/spring.rb +126 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming/welcome/controller.rb +23 -0
- data/lib/charming/welcome/show_view.rb +113 -0
- data/lib/charming/welcome.rb +13 -0
- data/lib/charming.rb +6 -0
- metadata +10 -7
- data/lib/charming/generators/templates/app/home_controller.template +0 -6
- data/lib/charming/generators/templates/app/home_state.template +0 -7
- data/lib/charming/generators/templates/app/spec_controller.template +0 -17
- data/lib/charming/generators/templates/app/spec_state.template +0 -17
- data/lib/charming/generators/templates/app/spec_view.template +0 -16
- data/lib/charming/generators/templates/app/view.template +0 -21
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module __APP_CLASS__
|
|
4
|
+
module Layouts
|
|
5
|
+
class ApplicationLayout < Charming::View
|
|
6
|
+
def render
|
|
7
|
+
screen_layout(background: theme.background) do
|
|
8
|
+
split(narrow? ? :vertical : :horizontal, gap: 1) do
|
|
9
|
+
pane(:sidebar, **sidebar_options, border: :rounded, padding: [1, 2], style: sidebar_style) do
|
|
10
|
+
column(app_title, navigation, shortcuts, gap: 1)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
pane(:content, grow: 1, border: :rounded, padding: [1, 2], style: content_style) do
|
|
14
|
+
yield_content
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
overlay command_palette_modal if command_palette_modal
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def palette_component
|
|
25
|
+
assigns.fetch(:palette, nil)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def narrow?
|
|
29
|
+
screen.narrow?(below: 72, min_height: 20)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sidebar_options
|
|
33
|
+
narrow? ? {height: [screen.height / 3, 5].max} : {width: 22}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def sidebar_inner_width
|
|
37
|
+
narrow? ? [screen.width - 6, 20].max : 16
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def app_title
|
|
41
|
+
text "__APP_NAME__", style: theme.header_accent.align(:center).width(sidebar_inner_width)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def navigation
|
|
45
|
+
column(*nav_items)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def nav_items
|
|
49
|
+
controller.sidebar_routes.each_with_index.map do |route, index|
|
|
50
|
+
text nav_item_label(route, index), style: nav_item_style(route, index)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def nav_item_label(route, index)
|
|
55
|
+
cursor = (sidebar_focused? && index == sidebar_index) ? ">" : " "
|
|
56
|
+
active = current_route?(route) ? "\u{25cf}" : " "
|
|
57
|
+
"#{cursor} #{active} #{route.title}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def nav_item_style(route, index)
|
|
61
|
+
if sidebar_focused? && index == sidebar_index
|
|
62
|
+
theme.selected
|
|
63
|
+
elsif current_route?(route)
|
|
64
|
+
theme.title
|
|
65
|
+
else
|
|
66
|
+
theme.muted
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def shortcuts
|
|
71
|
+
text "tab focus\nctrl+p commands\nq quit", style: theme.muted
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def sidebar_style
|
|
75
|
+
focused_style = sidebar_focused? ? theme.title : theme.border
|
|
76
|
+
palette_component ? focused_style.faint : focused_style
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def content_style
|
|
80
|
+
focused_style = content_focused? ? theme.title : theme.border
|
|
81
|
+
palette_component ? focused_style.faint : focused_style
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def command_palette_modal
|
|
85
|
+
return unless palette_component
|
|
86
|
+
|
|
87
|
+
render_component Charming::Components::CommandPaletteModal.new(
|
|
88
|
+
content: palette_component,
|
|
89
|
+
theme: theme
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def sidebar_focused?
|
|
94
|
+
controller.sidebar_focused?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def content_focused?
|
|
98
|
+
controller.content_focused?
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sidebar_index
|
|
102
|
+
controller.sidebar_index
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def current_route?(route)
|
|
106
|
+
controller.current_route?(route)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -82,6 +82,13 @@ module Charming
|
|
|
82
82
|
end.join("\n")
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Builds the `a=d,d=I` escape that deletes *image_id*'s placements AND frees its data from
|
|
86
|
+
# terminal memory (lowercase `d=i` would keep the data resident). Long-lived apps that cycle
|
|
87
|
+
# many images must emit this when evicting, or the terminal's image storage grows unbounded.
|
|
88
|
+
def delete(image_id:)
|
|
89
|
+
"\e_Ga=d,d=I,i=#{image_id},q=2\e\\"
|
|
90
|
+
end
|
|
91
|
+
|
|
85
92
|
# The chunked `a=t` (transmit) escapes carrying the base64 PNG data directly (`f=100,t=d`).
|
|
86
93
|
# Control keys ride only the first chunk; `m=1` flags more-to-come, `m=0` the last.
|
|
87
94
|
def transmit_image(image_id, png_bytes)
|
|
@@ -53,6 +53,16 @@ module Charming
|
|
|
53
53
|
protocol.placeholder_block(image_id: image_id, rows: rows, cols: cols)
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Builds the out-of-band {Transmit} that frees the image from terminal memory, or nil when
|
|
57
|
+
# graphics are unsupported. Re-arms {transmitted?} so a later render retransmits. Callers
|
|
58
|
+
# evicting an image register this the same way as {transmit}.
|
|
59
|
+
def release
|
|
60
|
+
return unless protocol
|
|
61
|
+
|
|
62
|
+
@transmitted = false
|
|
63
|
+
Transmit.new(image_id: image_id, payload: protocol.delete(image_id: image_id))
|
|
64
|
+
end
|
|
65
|
+
|
|
56
66
|
# True once {mark_transmitted} has recorded that the image was sent to the terminal.
|
|
57
67
|
def transmitted?
|
|
58
68
|
@transmitted
|
|
@@ -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,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
|
|
@@ -67,6 +67,13 @@ module Charming
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Forwards pasted text to the inner input and re-filters the suggestions.
|
|
71
|
+
def handle_paste(event)
|
|
72
|
+
result = @input.handle_paste(event)
|
|
73
|
+
clamp_selection if result
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
70
77
|
# Renders the input row followed by the suggestion dropdown.
|
|
71
78
|
def render
|
|
72
79
|
[input_line, *suggestion_lines].join("\n")
|
|
@@ -41,6 +41,11 @@ module Charming
|
|
|
41
41
|
nil
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Default paste handler returns nil (paste ignored). Text fields override.
|
|
45
|
+
def handle_paste(_event)
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
44
49
|
# Renders the field as a control line prefixed with ">" (active) or " " (inactive),
|
|
45
50
|
# optionally followed by the help line and any error lines.
|
|
46
51
|
def render(active: false)
|
|
@@ -27,17 +27,27 @@ module Charming
|
|
|
27
27
|
# Forwards key events to the underlying TextInput, syncing the value and cursor
|
|
28
28
|
# back into the form state. Returns :handled when the event was consumed.
|
|
29
29
|
def handle_key(event)
|
|
30
|
+
forward_to_input(:handle_key, event)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Forwards pasted text to the underlying TextInput the same way.
|
|
34
|
+
def handle_paste(event)
|
|
35
|
+
forward_to_input(:handle_paste, event)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# Sends *message* to a freshly-built TextInput and, when the widget consumed
|
|
41
|
+
# the event, persists the resulting value and cursor into the form state.
|
|
42
|
+
def forward_to_input(message, event)
|
|
30
43
|
text_input = input
|
|
31
|
-
|
|
32
|
-
return nil unless result == :handled
|
|
44
|
+
return nil unless text_input.public_send(message, event) == :handled
|
|
33
45
|
|
|
34
46
|
state[:values][name] = text_input.value
|
|
35
47
|
field_state[:cursor] = text_input.cursor
|
|
36
48
|
:handled
|
|
37
49
|
end
|
|
38
50
|
|
|
39
|
-
private
|
|
40
|
-
|
|
41
51
|
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
42
52
|
def default_value
|
|
43
53
|
@initial_value
|
|
@@ -29,15 +29,12 @@ module Charming
|
|
|
29
29
|
# Forwards key events to the underlying TextArea, syncing the value, cursor, offset,
|
|
30
30
|
# and preferred column back into the form state. Returns :handled when consumed.
|
|
31
31
|
def handle_key(event)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return nil unless result == :handled
|
|
32
|
+
forward_to_text_area(:handle_key, event)
|
|
33
|
+
end
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
field_state[:preferred_column] = area.preferred_column
|
|
40
|
-
:handled
|
|
35
|
+
# Forwards pasted text (newlines preserved) to the underlying TextArea the same way.
|
|
36
|
+
def handle_paste(event)
|
|
37
|
+
forward_to_text_area(:handle_paste, event)
|
|
41
38
|
end
|
|
42
39
|
|
|
43
40
|
# Renders the field with its label on the first line, body lines indented, and
|
|
@@ -50,6 +47,19 @@ module Charming
|
|
|
50
47
|
|
|
51
48
|
private
|
|
52
49
|
|
|
50
|
+
# Sends *message* to a freshly-built TextArea and, when the widget consumed the
|
|
51
|
+
# event, persists the value, cursor, offset, and preferred column into form state.
|
|
52
|
+
def forward_to_text_area(message, event)
|
|
53
|
+
area = text_area
|
|
54
|
+
return nil unless area.public_send(message, event) == :handled
|
|
55
|
+
|
|
56
|
+
state[:values][name] = area.value
|
|
57
|
+
field_state[:cursor] = area.cursor
|
|
58
|
+
field_state[:offset] = area.offset
|
|
59
|
+
field_state[:preferred_column] = area.preferred_column
|
|
60
|
+
:handled
|
|
61
|
+
end
|
|
62
|
+
|
|
53
63
|
# The default value for a freshly-bound field is the *value* passed at construction.
|
|
54
64
|
def default_value
|
|
55
65
|
@initial_value
|
|
@@ -38,6 +38,12 @@ module Charming
|
|
|
38
38
|
advance_or_submit if key == :enter
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
+
# Forwards pasted text to the currently focused field. Text fields insert it
|
|
42
|
+
# at their cursor; other fields ignore it.
|
|
43
|
+
def handle_paste(event)
|
|
44
|
+
current_field&.handle_paste(event)
|
|
45
|
+
end
|
|
46
|
+
|
|
41
47
|
# Forms accept free-typed text (their input/textarea fields do), so printable
|
|
42
48
|
# characters route here before global/content key bindings.
|
|
43
49
|
def captures_text?
|
|
@@ -39,8 +39,8 @@ module Charming
|
|
|
39
39
|
result
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
# Renders the modal as a bordered, padded string with the title
|
|
43
|
-
#
|
|
42
|
+
# Renders the modal as a bordered, padded string with the title above the content
|
|
43
|
+
# and the help footer below it.
|
|
44
44
|
def render
|
|
45
45
|
box(column(*lines, gap: 1), style: modal_style)
|
|
46
46
|
end
|
|
@@ -49,9 +49,9 @@ module Charming
|
|
|
49
49
|
|
|
50
50
|
attr_reader :content, :title, :help, :width
|
|
51
51
|
|
|
52
|
-
# Returns the array of non-nil lines: title,
|
|
52
|
+
# Returns the array of non-nil lines: title, content, help footer.
|
|
53
53
|
def lines
|
|
54
|
-
[title_line,
|
|
54
|
+
[title_line, body_content, help_line].compact
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# The body: windowed through a Viewport when scrollable, otherwise rendered directly.
|
|
@@ -8,7 +8,7 @@ module Charming
|
|
|
8
8
|
# for long buffers. Vertical movement preserves a "preferred column" so left/right
|
|
9
9
|
# navigation feels stable.
|
|
10
10
|
class TextArea < Component
|
|
11
|
-
# The current text value, cursor
|
|
11
|
+
# The current text value, cursor character offset, top-visible row offset, and remembered
|
|
12
12
|
# column for vertical navigation, respectively.
|
|
13
13
|
attr_reader :value, :cursor, :offset, :preferred_column
|
|
14
14
|
|
|
@@ -212,7 +212,7 @@ module Charming
|
|
|
212
212
|
[row, column]
|
|
213
213
|
end
|
|
214
214
|
|
|
215
|
-
# Returns the
|
|
215
|
+
# Returns the character offset where line *row* begins in the value.
|
|
216
216
|
def line_start(row)
|
|
217
217
|
lines.first(row).sum(&:length) + row
|
|
218
218
|
end
|
|
@@ -25,7 +25,7 @@ module Charming
|
|
|
25
25
|
delete: :delete_at_cursor
|
|
26
26
|
}.freeze
|
|
27
27
|
|
|
28
|
-
# The current input string and the
|
|
28
|
+
# The current input string and the character offset of the cursor within it.
|
|
29
29
|
attr_reader :value, :cursor
|
|
30
30
|
|
|
31
31
|
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
@@ -94,7 +94,7 @@ module Charming
|
|
|
94
94
|
!char.match?(/[[:cntrl:]]/)
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
-
# Inserts *char* at the cursor and advances the cursor by its
|
|
97
|
+
# Inserts *char* at the cursor and advances the cursor by its character length.
|
|
98
98
|
def insert(char)
|
|
99
99
|
@value = value[0...cursor] + char + value[cursor..]
|
|
100
100
|
@cursor += char.length
|
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
module Charming
|
|
4
4
|
module Components
|
|
5
5
|
# Toast is a small auto-dismissing notification panel, usually composited as an
|
|
6
|
-
# overlay anchored to a screen corner.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# overlay anchored to a screen corner. The component just renders the styled box;
|
|
7
|
+
# apps manage its lifetime themselves — typically a session-backed hash with an
|
|
8
|
+
# expiry deadline plus a controller timer that clears it (see the journal example's
|
|
9
|
+
# ApplicationController#show_toast / #expire_toast).
|
|
9
10
|
#
|
|
10
11
|
# Toast.new(message: "Saved!", kind: :success)
|
|
11
12
|
#
|
|
@@ -25,7 +25,10 @@ module Charming
|
|
|
25
25
|
# *content* may be a string, an array of lines, or any object responding to `render`.
|
|
26
26
|
# *width* and *height* constrain the visible window; *offset* is the top-visible row
|
|
27
27
|
# and *column* is the left-visible column. *wrap* enables soft-wrapping of long lines.
|
|
28
|
-
|
|
28
|
+
# *follow* pins the viewport to the bottom of the content regardless of *offset*, so
|
|
29
|
+
# callers that rebuild each event stick to new content; pair with `at_bottom?` to
|
|
30
|
+
# decide whether to keep following after the user scrolls.
|
|
31
|
+
def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim, follow: false)
|
|
29
32
|
super()
|
|
30
33
|
@content = content
|
|
31
34
|
@width = width
|
|
@@ -34,6 +37,7 @@ module Charming
|
|
|
34
37
|
@wrap = wrap
|
|
35
38
|
@keymap = keymap
|
|
36
39
|
position.clamp(bounds)
|
|
40
|
+
position.move_to(max_offset, bounds) if follow
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Renders the visible window of content as a multi-line string.
|
|
@@ -51,6 +55,12 @@ module Charming
|
|
|
51
55
|
@position.column
|
|
52
56
|
end
|
|
53
57
|
|
|
58
|
+
# True when the last content row is visible. Callers persisting follow mode
|
|
59
|
+
# store this after key dispatch to decide whether the next build follows.
|
|
60
|
+
def at_bottom?
|
|
61
|
+
offset >= max_offset
|
|
62
|
+
end
|
|
63
|
+
|
|
54
64
|
# Handles mouse events: scroll wheel adjusts the row offset, click moves the top
|
|
55
65
|
# visible row to the clicked position. Returns :handled on success.
|
|
56
66
|
def handle_mouse(event)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
# Projectile is simple physics motion for games: a position advanced by a
|
|
5
|
+
# velocity, and a velocity advanced by an acceleration (typically gravity),
|
|
6
|
+
# one fixed time step per frame (semi-implicit Euler, matching
|
|
7
|
+
# charmbracelet/harmonica's projectile):
|
|
8
|
+
#
|
|
9
|
+
# ball = Charming::Projectile.new(
|
|
10
|
+
# delta_time: Charming.fps(60),
|
|
11
|
+
# position: Charming::Projectile::Point.new(x: 0.0, y: 0.0),
|
|
12
|
+
# velocity: Charming::Projectile::Vector.new(x: 20.0, y: 0.0),
|
|
13
|
+
# acceleration: Charming::Projectile::TERMINAL_GRAVITY
|
|
14
|
+
# )
|
|
15
|
+
# position = ball.update # each frame
|
|
16
|
+
class Projectile
|
|
17
|
+
Point = Data.define(:x, :y, :z) do
|
|
18
|
+
def initialize(x:, y:, z: 0.0)
|
|
19
|
+
super
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Vector = Data.define(:x, :y, :z) do
|
|
24
|
+
def initialize(x:, y:, z: 0.0)
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gravity for a coordinate plane whose origin is bottom-left (y grows upward).
|
|
30
|
+
GRAVITY = Vector.new(x: 0.0, y: -9.81)
|
|
31
|
+
|
|
32
|
+
# Gravity for terminal coordinates, whose origin is top-left (y grows downward).
|
|
33
|
+
TERMINAL_GRAVITY = Vector.new(x: 0.0, y: 9.81)
|
|
34
|
+
|
|
35
|
+
attr_reader :position, :velocity, :acceleration
|
|
36
|
+
|
|
37
|
+
def initialize(delta_time:, position:, velocity: Vector.new(x: 0.0, y: 0.0), acceleration: Vector.new(x: 0.0, y: 0.0))
|
|
38
|
+
@delta_time = delta_time
|
|
39
|
+
@position = position
|
|
40
|
+
@velocity = velocity
|
|
41
|
+
@acceleration = acceleration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Advances one frame and returns the new position. Position moves by the
|
|
45
|
+
# current velocity before the velocity accelerates — keep this order.
|
|
46
|
+
def update
|
|
47
|
+
@position = shift(position, velocity)
|
|
48
|
+
@velocity = shift(velocity, acceleration)
|
|
49
|
+
position
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
attr_reader :delta_time
|
|
55
|
+
|
|
56
|
+
def shift(value, rate)
|
|
57
|
+
value.with(
|
|
58
|
+
x: value.x + rate.x * delta_time,
|
|
59
|
+
y: value.y + rate.y * delta_time,
|
|
60
|
+
z: value.z + rate.z * delta_time
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/charming/runtime.rb
CHANGED
|
@@ -16,10 +16,14 @@ module Charming
|
|
|
16
16
|
@task_queue = Thread::Queue.new
|
|
17
17
|
@task_executor = build_task_executor(task_executor)
|
|
18
18
|
@application.task_executor = @task_executor
|
|
19
|
-
@route =
|
|
19
|
+
@route = resolve_route("/")
|
|
20
20
|
@screen = backend_screen
|
|
21
21
|
@coalesce_input = @application.respond_to?(:coalesce_input?) && @application.coalesce_input?
|
|
22
22
|
@event_loop = build_event_loop
|
|
23
|
+
@application.timer_control = Internal::TimerControl.new(
|
|
24
|
+
event_loop: @event_loop,
|
|
25
|
+
bindings: -> { @route.controller_class.timer_bindings }
|
|
26
|
+
)
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# Runs the event loop: enters alt-screen, dispatches incoming events
|
|
@@ -52,12 +56,18 @@ module Charming
|
|
|
52
56
|
backend: @backend,
|
|
53
57
|
clock: @clock,
|
|
54
58
|
task_queue: @task_queue,
|
|
55
|
-
timer_bindings:
|
|
59
|
+
timer_bindings: autostart_timer_bindings,
|
|
56
60
|
coalesce_input: @coalesce_input,
|
|
57
61
|
interrupted: -> { @interrupted }
|
|
58
62
|
)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# The current route's timers that run from boot. Timers declared with
|
|
66
|
+
# `autostart: false` (including `animate` ticks) wait for `start_timer`.
|
|
67
|
+
def autostart_timer_bindings
|
|
68
|
+
@route.controller_class.timer_bindings.values.select(&:autostart)
|
|
69
|
+
end
|
|
70
|
+
|
|
61
71
|
# The first frame's response — the root route's action, with errors caught. Out-of-band escape
|
|
62
72
|
# sequences registered while rendering are collected and attached to the response.
|
|
63
73
|
def initial_response
|
|
@@ -281,11 +291,22 @@ module Charming
|
|
|
281
291
|
def resolve_response(response)
|
|
282
292
|
return response unless response.navigate?
|
|
283
293
|
|
|
284
|
-
@route =
|
|
285
|
-
@event_loop.reset_timers(
|
|
294
|
+
@route = resolve_route(response.path)
|
|
295
|
+
@event_loop.reset_timers(autostart_timer_bindings)
|
|
286
296
|
dispatch(@route.action)
|
|
287
297
|
end
|
|
288
298
|
|
|
299
|
+
# Resolves *path* from the app's router. An unrouted "/" falls back to the app's
|
|
300
|
+
# first route, or to the built-in welcome screen when no routes are defined yet
|
|
301
|
+
# (like Rails' welcome page); other unrouted paths still raise.
|
|
302
|
+
def resolve_route(path)
|
|
303
|
+
@application.routes.resolve(path)
|
|
304
|
+
rescue KeyError
|
|
305
|
+
raise unless path == "/"
|
|
306
|
+
|
|
307
|
+
@application.routes.all.first || Welcome.route
|
|
308
|
+
end
|
|
309
|
+
|
|
289
310
|
# Derives Screen dimensions (width, height) from the terminal backend.
|
|
290
311
|
def backend_screen
|
|
291
312
|
width, height = @backend.size
|