charming 0.2.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 651009e81e5bb2eb02b2df432b5e399773150691413f2519a6888160b60bd1ca
4
- data.tar.gz: 8aa3bf4aebc1abab7890eec9b68791d4ed0fd354cffb982cc40f2cf4f261f70c
3
+ metadata.gz: fb4da799ae87f28a2495a56dd47dcb049c5b532397993976a5d56fcb2a51a518
4
+ data.tar.gz: 070767c2b15058ff97db6d4aec1da299f0f8a7df9e249ac323ef9ff1329764ac
5
5
  SHA512:
6
- metadata.gz: d4276dd6ef588077877b4d4d44758a9c176161fccddd22a5d74161f8615ee38f3c8078872a0ccbca6c8d73e83c994f7e0221f45f75348b9b81bef507a35ff491
7
- data.tar.gz: 595008aad5caa6c524e872593b8554b588c8018592aca1ead16820200c31dec67cb1189e338658e902abafa9033612780441ed71bb57bcdb8f296b8f03bbfecc
6
+ metadata.gz: c4a76d6edd2439e200ea88d1ec13f8be7d4a74974770fd091de041f3f0c03c953edc0ae72b53ca0aed3dd9648f924d3c693d9a696ea5a727d2d351dc6f04e660
7
+ data.tar.gz: da98baee0f32faa72560af2b6f1f05f80b61996a272739145774103d42d672d536c5b00634082042ee0a3d4d919eb9f53080f2c143ffb1b7d7563d9b7da86b89
data/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  A Rails-inspired terminal user interface framework for **Ruby 4+**.
6
6
 
7
- Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, background tasks, cross-platform audio playback, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, and testable terminal backends.
7
+ Charming gives terminal apps familiar application structure: routes, controllers, state objects, templates, layouts, reusable components, themes, keyboard bindings, command palettes, timers, physics-based animation (springs and projectiles, ported from charmbracelet/harmonica), background tasks, cross-platform audio playback, inline image display (Kitty graphics protocol), system clipboard / desktop notifications / window title, braille charts and sparklines, and testable terminal backends.
8
8
 
9
9
  ## Project Status
10
10
 
@@ -163,6 +163,13 @@ module Charming
163
163
  end
164
164
 
165
165
  attr_accessor :logger, :task_executor
166
+ attr_writer :timer_control
167
+
168
+ # The runtime-injected timer control used by `Controller#start_timer` and
169
+ # friends. Defaults to a null object so controllers work outside a Runtime.
170
+ def timer_control
171
+ @timer_control ||= Internal::TimerControl::Null.new
172
+ end
166
173
  attr_reader :session
167
174
 
168
175
  # Initializes the session hash for per-request state storage, restoring a
@@ -24,10 +24,21 @@ module Charming
24
24
 
25
25
  # Declares a timer that fires every *every* seconds and dispatches *action* on the controller.
26
26
  # The runtime builds a TimerEvent and routes it to the active controller's dispatch_timer.
27
- def timer(name, every:, action:)
27
+ # Timers run from boot by default; declare `autostart: false` to schedule one only when an
28
+ # action calls `start_timer`.
29
+ def timer(name, every:, action:, autostart: true)
28
30
  raise ArgumentError, "timer interval must be positive (got #{every.inspect})" unless every.is_a?(Numeric) && every.positive?
29
31
 
30
- timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action)
32
+ timer_bindings[name.to_sym] = TimerBinding.new(name: name.to_sym, interval: every, action: action, autostart: autostart)
33
+ end
34
+
35
+ # Declares an animation: a stopped timer ticking *action* at *fps* frames per second.
36
+ # Begin motion with `start_timer(name)`; the action calls `stop_timer(name)` once the
37
+ # motion settles, returning the app to zero idle cost.
38
+ def animate(name, action:, fps: 30)
39
+ raise ArgumentError, "fps must be positive (got #{fps.inspect})" unless fps.is_a?(Numeric) && fps.positive?
40
+
41
+ timer(name, every: 1.0 / fps, action: action, autostart: false)
31
42
  end
32
43
 
33
44
  # Declares a task handler for async work submitted via `run_task(:name)`. When the task emits
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ class Controller
5
+ # Timer control helpers mixed into Controller. Controllers are ephemeral, so
6
+ # starting and stopping named timers is delegated to the runtime-owned
7
+ # TimerControl exposed on the application (a null object outside a runtime).
8
+ module Timers
9
+ # Schedules the named declared timer. Idempotent while the timer is running.
10
+ def start_timer(name)
11
+ application.timer_control.start(name)
12
+ end
13
+
14
+ # Unschedules the named timer. Idempotent when it is not running.
15
+ def stop_timer(name)
16
+ application.timer_control.stop(name)
17
+ end
18
+
19
+ # True while the named timer is scheduled.
20
+ def timer_running?(name)
21
+ application.timer_control.running?(name)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -5,7 +5,11 @@ module Charming
5
5
  # It provides the action dispatch pipeline, key/command/timer/task bindings, sidebar navigation,
6
6
  # command palette management, and view rendering with layout composition.
7
7
  class Controller
8
- TimerBinding = Data.define(:name, :interval, :action)
8
+ TimerBinding = Data.define(:name, :interval, :action, :autostart) do
9
+ def initialize(name:, interval:, action:, autostart: true)
10
+ super
11
+ end
12
+ end
9
13
  TaskBinding = Data.define(:name, :action)
10
14
 
11
15
  extend ClassMethods
@@ -18,6 +22,7 @@ module Charming
18
22
  include ComponentDispatching
19
23
  include Dispatching
20
24
  include Terminal
25
+ include Timers
21
26
 
22
27
  attr_reader :application, :event, :params, :screen, :route
23
28
 
@@ -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
@@ -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
@@ -20,6 +20,10 @@ module Charming
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
@@ -282,7 +292,7 @@ module Charming
282
292
  return response unless response.navigate?
283
293
 
284
294
  @route = resolve_route(response.path)
285
- @event_loop.reset_timers(@route.controller_class.timer_bindings.values)
295
+ @event_loop.reset_timers(autostart_timer_bindings)
286
296
  dispatch(@route.action)
287
297
  end
288
298
 
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ******************************************************************************
4
+ #
5
+ # Copyright (c) 2008-2012 Ryan Juckett
6
+ # http://www.ryanjuckett.com/
7
+ #
8
+ # This software is provided 'as-is', without any express or implied
9
+ # warranty. In no event will the authors be held liable for any damages
10
+ # arising from the use of this software.
11
+ #
12
+ # Permission is granted to anyone to use this software for any purpose,
13
+ # including commercial applications, and to alter it and redistribute it
14
+ # freely, subject to the following restrictions:
15
+ #
16
+ # 1. The origin of this software must not be misrepresented; you must not
17
+ # claim that you wrote the original software. If you use this software
18
+ # in a product, an acknowledgment in the product documentation would be
19
+ # appreciated but is not required.
20
+ #
21
+ # 2. Altered source versions must be plainly marked as such, and must not be
22
+ # misrepresented as being the original software.
23
+ #
24
+ # 3. This notice may not be removed or altered from any source
25
+ # distribution.
26
+ #
27
+ # ******************************************************************************
28
+ #
29
+ # Ported to Go by Charmbracelet, Inc. in 2021 (charmbracelet/harmonica, MIT).
30
+ # Ported to Ruby for Charming in 2026 from charmbracelet/harmonica.
31
+ #
32
+ # For background on the algorithm see:
33
+ # https://www.ryanjuckett.com/damped-springs/
34
+
35
+ module Charming
36
+ # Spring is a damped harmonic oscillator for physics-based animation. It
37
+ # precomputes motion coefficients for a fixed time step so each frame update
38
+ # is four multiply-adds. Instances are immutable; the caller owns position
39
+ # and velocity (typically as Float attributes on an ApplicationState) and
40
+ # feeds them back in every frame:
41
+ #
42
+ # SPRING = Charming::Spring.new(delta_time: Charming.fps(60))
43
+ # position, velocity = SPRING.update(position, velocity, target)
44
+ #
45
+ # The damping ratio shapes the motion: below 1 overshoots and oscillates,
46
+ # exactly 1 reaches the target as fast as possible without overshooting,
47
+ # above 1 approaches slower still. Angular frequency scales the speed.
48
+ class Spring
49
+ def initialize(delta_time:, angular_frequency: 6.0, damping_ratio: 0.5)
50
+ angular_frequency = [0.0, angular_frequency].max
51
+ damping_ratio = [0.0, damping_ratio].max
52
+ @pos_pos, @pos_vel, @vel_pos, @vel_vel =
53
+ coefficients(delta_time, angular_frequency, damping_ratio)
54
+ freeze
55
+ end
56
+
57
+ # Advances one frame toward *target*, returning `[new_position, new_velocity]`.
58
+ def update(position, velocity, target)
59
+ relative = position - target
60
+ [relative * @pos_pos + velocity * @pos_vel + target,
61
+ relative * @vel_pos + velocity * @vel_vel]
62
+ end
63
+
64
+ # True once the motion has effectively come to rest at *target* — the guard
65
+ # for stopping an animation timer.
66
+ def settled?(position, velocity, target, epsilon: 0.01)
67
+ (position - target).abs < epsilon && velocity.abs < epsilon
68
+ end
69
+
70
+ private
71
+
72
+ def coefficients(delta_time, frequency, ratio)
73
+ return [1.0, 0.0, 0.0, 1.0] if frequency < Float::EPSILON
74
+
75
+ if ratio > 1.0 + Float::EPSILON
76
+ over_damped(delta_time, frequency, ratio)
77
+ elsif ratio < 1.0 - Float::EPSILON
78
+ under_damped(delta_time, frequency, ratio)
79
+ else
80
+ critically_damped(delta_time, frequency)
81
+ end
82
+ end
83
+
84
+ def over_damped(delta_time, frequency, ratio)
85
+ za = -frequency * ratio
86
+ zb = frequency * Math.sqrt(ratio * ratio - 1.0)
87
+ z1 = za - zb
88
+ z2 = za + zb
89
+ e2 = Math.exp(z2 * delta_time)
90
+ e1_over_two_zb = Math.exp(z1 * delta_time) / (2.0 * zb)
91
+ e2_over_two_zb = e2 / (2.0 * zb)
92
+ z1e1_over_two_zb = z1 * e1_over_two_zb
93
+ z2e2_over_two_zb = z2 * e2_over_two_zb
94
+
95
+ [e1_over_two_zb * z2 - z2e2_over_two_zb + e2,
96
+ -e1_over_two_zb + e2_over_two_zb,
97
+ (z1e1_over_two_zb - z2e2_over_two_zb + e2) * z2,
98
+ -z1e1_over_two_zb + z2e2_over_two_zb]
99
+ end
100
+
101
+ def under_damped(delta_time, frequency, ratio)
102
+ omega_zeta = frequency * ratio
103
+ alpha = frequency * Math.sqrt(1.0 - ratio * ratio)
104
+ exp_term = Math.exp(-omega_zeta * delta_time)
105
+ exp_sin = exp_term * Math.sin(alpha * delta_time)
106
+ exp_cos = exp_term * Math.cos(alpha * delta_time)
107
+ exp_omega_zeta_sin_over_alpha = omega_zeta * exp_sin / alpha
108
+
109
+ [exp_cos + exp_omega_zeta_sin_over_alpha,
110
+ exp_sin / alpha,
111
+ -exp_sin * alpha - omega_zeta * exp_omega_zeta_sin_over_alpha,
112
+ exp_cos - exp_omega_zeta_sin_over_alpha]
113
+ end
114
+
115
+ def critically_damped(delta_time, frequency)
116
+ exp_term = Math.exp(-frequency * delta_time)
117
+ time_exp = delta_time * exp_term
118
+ time_exp_freq = time_exp * frequency
119
+
120
+ [time_exp_freq + exp_term,
121
+ time_exp,
122
+ -frequency * time_exp_freq,
123
+ -time_exp_freq + exp_term]
124
+ end
125
+ end
126
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- VERSION = "0.2.3"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/charming.rb CHANGED
@@ -43,6 +43,12 @@ module Charming
43
43
  Runtime.new(application, backend: backend).run
44
44
  end
45
45
 
46
+ # Returns the seconds-per-frame time delta for a frame rate, for initializing
47
+ # physics primitives: `Charming::Spring.new(delta_time: Charming.fps(60))`.
48
+ def self.fps(frames_per_second)
49
+ 1.0 / frames_per_second
50
+ end
51
+
46
52
  # Returns the normalized key symbol for an event-like object — `event.key` when the object responds
47
53
  # to it, otherwise `event.to_sym`. Lets components treat raw strings and KeyEvent objects uniformly.
48
54
  def self.key_of(event)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: charming
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - pando
@@ -206,6 +206,7 @@ files:
206
206
  - lib/charming/controller/session_state.rb
207
207
  - lib/charming/controller/sidebar_navigation.rb
208
208
  - lib/charming/controller/terminal.rb
209
+ - lib/charming/controller/timers.rb
209
210
  - lib/charming/database/commands.rb
210
211
  - lib/charming/escape.rb
211
212
  - lib/charming/events/focus_event.rb
@@ -276,6 +277,7 @@ files:
276
277
  - lib/charming/internal/terminal/modified_key_parser.rb
277
278
  - lib/charming/internal/terminal/mouse_parser.rb
278
279
  - lib/charming/internal/terminal/tty_backend.rb
280
+ - lib/charming/internal/timer_control.rb
279
281
  - lib/charming/presentation/component.rb
280
282
  - lib/charming/presentation/components/activity_indicator.rb
281
283
  - lib/charming/presentation/components/audio.rb
@@ -366,10 +368,12 @@ files:
366
368
  - lib/charming/presentation/ui/truncate.rb
367
369
  - lib/charming/presentation/ui/width.rb
368
370
  - lib/charming/presentation/view.rb
371
+ - lib/charming/projectile.rb
369
372
  - lib/charming/response.rb
370
373
  - lib/charming/router.rb
371
374
  - lib/charming/runtime.rb
372
375
  - lib/charming/screen.rb
376
+ - lib/charming/spring.rb
373
377
  - lib/charming/tasks/cancelled.rb
374
378
  - lib/charming/tasks/inline_executor.rb
375
379
  - lib/charming/tasks/progress.rb