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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -3
  3. data/lib/charming/application.rb +7 -0
  4. data/lib/charming/cli.rb +16 -4
  5. data/lib/charming/controller/class_methods.rb +13 -2
  6. data/lib/charming/controller/timers.rb +25 -0
  7. data/lib/charming/controller.rb +7 -2
  8. data/lib/charming/generators/app_file_generator.rb +29 -0
  9. data/lib/charming/generators/app_generator.rb +12 -25
  10. data/lib/charming/generators/base.rb +5 -0
  11. data/lib/charming/generators/layout_generator.rb +155 -0
  12. data/lib/charming/generators/screen_generator.rb +0 -29
  13. data/lib/charming/generators/templates/app/README.md.template +15 -1
  14. data/lib/charming/generators/templates/app/application.template +0 -6
  15. data/lib/charming/generators/templates/app/application_controller.template +0 -10
  16. data/lib/charming/generators/templates/app/layout.template +4 -93
  17. data/lib/charming/generators/templates/app/routes.template +3 -1
  18. data/lib/charming/generators/templates/layout/sidebar/application_layout.rb.template +110 -0
  19. data/lib/charming/image/protocol/kitty.rb +7 -0
  20. data/lib/charming/image/source.rb +10 -0
  21. data/lib/charming/internal/event_loop.rb +25 -2
  22. data/lib/charming/internal/timer_control.rb +48 -0
  23. data/lib/charming/presentation/components/autocomplete.rb +7 -0
  24. data/lib/charming/presentation/components/form/field.rb +5 -0
  25. data/lib/charming/presentation/components/form/input.rb +14 -4
  26. data/lib/charming/presentation/components/form/textarea.rb +18 -8
  27. data/lib/charming/presentation/components/form.rb +6 -0
  28. data/lib/charming/presentation/components/modal.rb +4 -4
  29. data/lib/charming/presentation/components/stopwatch.rb +1 -1
  30. data/lib/charming/presentation/components/text_area.rb +2 -2
  31. data/lib/charming/presentation/components/text_input.rb +2 -2
  32. data/lib/charming/presentation/components/timer.rb +1 -1
  33. data/lib/charming/presentation/components/toast.rb +4 -3
  34. data/lib/charming/presentation/components/viewport.rb +11 -1
  35. data/lib/charming/projectile.rb +64 -0
  36. data/lib/charming/runtime.rb +25 -4
  37. data/lib/charming/spring.rb +126 -0
  38. data/lib/charming/version.rb +1 -1
  39. data/lib/charming/welcome/controller.rb +23 -0
  40. data/lib/charming/welcome/show_view.rb +113 -0
  41. data/lib/charming/welcome.rb +13 -0
  42. data/lib/charming.rb +6 -0
  43. metadata +10 -7
  44. data/lib/charming/generators/templates/app/home_controller.template +0 -6
  45. data/lib/charming/generators/templates/app/home_state.template +0 -7
  46. data/lib/charming/generators/templates/app/spec_controller.template +0 -17
  47. data/lib/charming/generators/templates/app/spec_state.template +0 -17
  48. data/lib/charming/generators/templates/app/spec_view.template +0 -16
  49. data/lib/charming/generators/templates/app/view.template +0 -21
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  __APP_CLASS__::Application.routes do
4
- root "home#show"
4
+ # Generate your first screen with `charming generate screen NAME`, then make it home:
5
+ #
6
+ # root "home#show"
5
7
  end
@@ -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
- timer[:next_at] = now + timer.fetch(:binding).interval
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
- result = text_input.handle_key(event)
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
- area = text_area
33
- result = area.handle_key(event)
34
- return nil unless result == :handled
32
+ forward_to_text_area(:handle_key, event)
33
+ end
35
34
 
36
- state[:values][name] = area.value
37
- field_state[:cursor] = area.cursor
38
- field_state[:offset] = area.offset
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 and help lines stacked
43
- # above the content.
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, help, content.
52
+ # Returns the array of non-nil lines: title, content, help footer.
53
53
  def lines
54
- [title_line, help_line, body_content].compact
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.
@@ -34,7 +34,7 @@ module Charming
34
34
 
35
35
  # Adds *seconds* (default 1) when running; a no-op otherwise. Returns self.
36
36
  def tick(seconds = 1)
37
- @elapsed += seconds.to_i if running?
37
+ @elapsed += seconds if running?
38
38
  self
39
39
  end
40
40
 
@@ -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 byte offset, top-visible row offset, and remembered
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 byte offset where line *row* begins in the value.
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 byte offset of the cursor within it.
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 byte length.
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
@@ -18,7 +18,7 @@ module Charming
18
18
 
19
19
  # Counts down by *seconds* (default 1), clamping at zero. Returns self.
20
20
  def tick(seconds = 1)
21
- @remaining = [@remaining - seconds.to_i, 0].max
21
+ @remaining = [@remaining - seconds, 0].max
22
22
  self
23
23
  end
24
24
 
@@ -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. Controllers manage its lifetime with the
7
- # `show_toast` / `dismiss_toast` helpers (which pair it with a timer); the component
8
- # itself just renders the styled box.
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
- def initialize(content:, width: nil, height: nil, offset: 0, column: 0, wrap: false, keymap: :vim)
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
@@ -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 = @application.routes.resolve("/")
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: @route.controller_class.timer_bindings.values,
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 = @application.routes.resolve(response.path)
285
- @event_loop.reset_timers(@route.controller_class.timer_bindings.values)
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