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
@@ -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.2"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Welcome
5
+ # Controller for the built-in welcome screen. Renders without an app layout and
6
+ # binds only `q` to quit.
7
+ class Controller < Charming::Controller
8
+ key "q", :quit, scope: :global
9
+
10
+ def show
11
+ render_view ShowView, app_name: app_display_name
12
+ end
13
+
14
+ private
15
+
16
+ # The app's namespace for the welcome heading, or "Charming" for anonymous apps.
17
+ def app_display_name
18
+ name = application.class.namespace
19
+ name.to_s.empty? ? "Charming" : name
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Welcome
5
+ # The welcome screen body: a pastel Miami-deco skyline over the app name, tagline,
6
+ # Charming version, and project link, centered on the terminal.
7
+ class ShowView < Charming::View
8
+ SKYLINE_COLORS = {
9
+ "F" => "#f0437c", # flamingo
10
+ "B" => "#f9cfd8", # blush
11
+ "M" => "#bfe8dc", # mint
12
+ "C" => "#f3e6c9", # cream
13
+ "P" => "#fdfaf3", # plaster
14
+ "G" => "#1d4b44" # palm green
15
+ }.freeze
16
+
17
+ # Rows of [art, color mask] pairs; each mask letter colors the glyph above it.
18
+ SKYLINE = [
19
+ [" ▲ ",
20
+ " F "],
21
+ [" █ ",
22
+ " F "],
23
+ [" ▄▄▄ ▄█▄ ▄▄▄ ",
24
+ " BBB PFP MMM "],
25
+ [" ███ ▐███▌ ███ ",
26
+ " BBB PPPPP MMM "],
27
+ [" █▀▀▀█ ▐███▌ █▀▀▀█ ",
28
+ " BBBBB PPPPP MMMMM "],
29
+ [" ▄█▄ █████ ▐█████▌ █████ ▄█▄ ",
30
+ " GGG BBBBB PPPPPPP MMMMM GGG "],
31
+ [" █ ▄▄▄ ██▀▀▀██ ▐█████▌ ██▀▀▀██ ▄▄▄ █ ",
32
+ " G CCC BBBBBBB PPPPPPP MMMMMMM CCC G "],
33
+ [" █ ███ ███████ ▐███████▌ ███████ ███ █ ",
34
+ " G CCC BBBBBBB PPPPPPPPP MMMMMMM CCC G "],
35
+ [" █ ███ ███████ ▐███████▌ ███████ ███ █ ",
36
+ " G CCC BBBBBBB PPPPPPPPP MMMMMMM CCC G "],
37
+ ["▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁",
38
+ "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"]
39
+ ].freeze
40
+ SKYLINE_WIDTH = SKYLINE.map { |art, _mask| art.length }.max
41
+ SKYLINE_HEIGHT = SKYLINE.length
42
+
43
+ def render
44
+ screen_layout(background: theme.background) do
45
+ pane(style: centered) do
46
+ welcome_content
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def centered
54
+ style.align(:center).align_vertical(:middle)
55
+ end
56
+
57
+ def welcome_content
58
+ column(*blocks, gap: 1, align: :center)
59
+ end
60
+
61
+ def blocks
62
+ [skyline, heading, tagline, footer, hint].compact
63
+ end
64
+
65
+ def skyline
66
+ return unless skyline_fits?
67
+
68
+ column(*SKYLINE.map { |art, mask| paint_row(art, mask) })
69
+ end
70
+
71
+ # Leave room below the art for the heading, tagline, footer, and hint lines.
72
+ def skyline_fits?
73
+ layout_screen.width >= SKYLINE_WIDTH && layout_screen.height >= SKYLINE_HEIGHT + 8
74
+ end
75
+
76
+ def paint_row(art, mask)
77
+ color_runs(art, mask).map { |run, key| paint(run, key) }.join
78
+ end
79
+
80
+ # Groups adjacent glyphs sharing a mask letter into [run, letter] pairs.
81
+ def color_runs(art, mask)
82
+ art.chars.zip(mask.chars)
83
+ .chunk { |_glyph, key| key || " " }
84
+ .map { |key, pairs| [pairs.map(&:first).join, key] }
85
+ end
86
+
87
+ def paint(run, key)
88
+ color = SKYLINE_COLORS[key]
89
+ color ? style.foreground(color).render(run) : run
90
+ end
91
+
92
+ def heading
93
+ text app_name, style: theme.title
94
+ end
95
+
96
+ def tagline
97
+ text "A Rails-inspired Ruby TUI framework", style: theme.text
98
+ end
99
+
100
+ def footer
101
+ column(
102
+ text("Charming v#{Charming::VERSION}", style: theme.muted),
103
+ text("https://github.com/pandorocks/charming", style: theme.muted),
104
+ align: :center
105
+ )
106
+ end
107
+
108
+ def hint
109
+ text "Define a root route in config/routes.rb to replace this screen.", style: theme.muted
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ # Welcome is the built-in placeholder screen shown when an application defines no
5
+ # routes yet — the TUI equivalent of Rails' welcome page. It lives in the gem and is
6
+ # never copied into apps: defining any route in config/routes.rb replaces it.
7
+ module Welcome
8
+ # The fallback route the Runtime uses when the application has no routes.
9
+ def self.route
10
+ Router::Route.new(path: "/", controller_class: Controller, action: :show, title: "Welcome", params: {})
11
+ end
12
+ end
13
+ 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.2
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
@@ -223,6 +224,7 @@ files:
223
224
  - lib/charming/generators/controller_generator.rb
224
225
  - lib/charming/generators/database_installer.rb
225
226
  - lib/charming/generators/error.rb
227
+ - lib/charming/generators/layout_generator.rb
226
228
  - lib/charming/generators/migration_generator.rb
227
229
  - lib/charming/generators/migration_timestamp.rb
228
230
  - lib/charming/generators/model_generator.rb
@@ -239,21 +241,16 @@ files:
239
241
  - lib/charming/generators/templates/app/dot_rspec.template
240
242
  - lib/charming/generators/templates/app/executable.template
241
243
  - lib/charming/generators/templates/app/gemspec.template
242
- - lib/charming/generators/templates/app/home_controller.template
243
- - lib/charming/generators/templates/app/home_state.template
244
244
  - lib/charming/generators/templates/app/keep.template
245
245
  - lib/charming/generators/templates/app/layout.template
246
246
  - lib/charming/generators/templates/app/root_file.template
247
247
  - lib/charming/generators/templates/app/routes.template
248
248
  - lib/charming/generators/templates/app/seeds.template
249
- - lib/charming/generators/templates/app/spec_controller.template
250
249
  - lib/charming/generators/templates/app/spec_helper.template
251
- - lib/charming/generators/templates/app/spec_state.template
252
- - lib/charming/generators/templates/app/spec_view.template
253
250
  - lib/charming/generators/templates/app/version.template
254
- - lib/charming/generators/templates/app/view.template
255
251
  - lib/charming/generators/templates/component/component.rb.template
256
252
  - lib/charming/generators/templates/controller/controller.rb.template
253
+ - lib/charming/generators/templates/layout/sidebar/application_layout.rb.template
257
254
  - lib/charming/generators/templates/model/migration.rb.template
258
255
  - lib/charming/generators/templates/model/model.rb.template
259
256
  - lib/charming/generators/templates/model/spec.rb.template
@@ -280,6 +277,7 @@ files:
280
277
  - lib/charming/internal/terminal/modified_key_parser.rb
281
278
  - lib/charming/internal/terminal/mouse_parser.rb
282
279
  - lib/charming/internal/terminal/tty_backend.rb
280
+ - lib/charming/internal/timer_control.rb
283
281
  - lib/charming/presentation/component.rb
284
282
  - lib/charming/presentation/components/activity_indicator.rb
285
283
  - lib/charming/presentation/components/audio.rb
@@ -370,10 +368,12 @@ files:
370
368
  - lib/charming/presentation/ui/truncate.rb
371
369
  - lib/charming/presentation/ui/width.rb
372
370
  - lib/charming/presentation/view.rb
371
+ - lib/charming/projectile.rb
373
372
  - lib/charming/response.rb
374
373
  - lib/charming/router.rb
375
374
  - lib/charming/runtime.rb
376
375
  - lib/charming/screen.rb
376
+ - lib/charming/spring.rb
377
377
  - lib/charming/tasks/cancelled.rb
378
378
  - lib/charming/tasks/inline_executor.rb
379
379
  - lib/charming/tasks/progress.rb
@@ -381,6 +381,9 @@ files:
381
381
  - lib/charming/tasks/threaded_executor.rb
382
382
  - lib/charming/test_helper.rb
383
383
  - lib/charming/version.rb
384
+ - lib/charming/welcome.rb
385
+ - lib/charming/welcome/controller.rb
386
+ - lib/charming/welcome/show_view.rb
384
387
  - sig/charming.rbs
385
388
  homepage: https://github.com/pandorocks/charming
386
389
  licenses:
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module __APP_CLASS__
4
- class HomeController < ApplicationController
5
- __CONTROLLER_ACTIONS____CONTROLLER_HELPERS__ end
6
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module __APP_CLASS__
4
- class HomeState < ApplicationState
5
- attribute :title, :string, default: "__APP_NAME__"
6
- end
7
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "__APP_SNAKE__"
4
-
5
- RSpec.describe __APP_CLASS__::HomeController do
6
- let(:application) { __APP_CLASS__::Application.new }
7
-
8
- subject(:controller) { described_class.new(application: application) }
9
-
10
- describe "#show" do
11
- it "renders the view with the state" do
12
- response = controller.dispatch(:show)
13
-
14
- expect(response).to respond_to(:body)
15
- end
16
- end
17
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "__APP_SNAKE__"
4
-
5
- RSpec.describe __APP_CLASS__::HomeState do
6
- describe "#title" do
7
- it "has the correct default string value" do
8
- instance = described_class.new
9
- expect(instance.title).to eq("__APP_NAME__")
10
- end
11
-
12
- it "accepts overridden title values" do
13
- instance = described_class.new(title: "Alternative")
14
- expect(instance.title).to eq("Alternative")
15
- end
16
- end
17
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "__APP_SNAKE__"
4
-
5
- RSpec.describe __APP_CLASS__::Home::ShowView do
6
- describe "#render" do
7
- it "renders the state title" do
8
- view = described_class.new(
9
- home: double(title: "__APP_NAME__"),
10
- theme: __APP_CLASS__::Application.new.theme
11
- )
12
-
13
- expect(view.render).to include("__APP_NAME__")
14
- end
15
- end
16
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module __APP_CLASS__
4
- module Home
5
- class ShowView < Charming::View
6
- def render
7
- column(title_line, help_line, gap: 1)
8
- end
9
-
10
- private
11
-
12
- def title_line
13
- text home.title, style: theme.title
14
- end
15
-
16
- def help_line
17
- text "Press ctrl+p for commands, q to quit.", style: theme.muted
18
- end
19
- end
20
- end
21
- end