charming 0.1.1 → 0.1.2

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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/charming/application.rb +11 -0
  4. data/lib/charming/cli.rb +23 -0
  5. data/lib/charming/controller/class_methods.rb +115 -0
  6. data/lib/charming/controller/command_palette.rb +135 -0
  7. data/lib/charming/controller/component_dispatching.rb +81 -0
  8. data/lib/charming/controller/dispatching.rb +60 -0
  9. data/lib/charming/controller/focus_management.rb +30 -0
  10. data/lib/charming/controller/rendering.rb +127 -0
  11. data/lib/charming/controller/session_state.rb +41 -0
  12. data/lib/charming/controller/sidebar_navigation.rb +111 -0
  13. data/lib/charming/controller.rb +35 -559
  14. data/lib/charming/database_commands.rb +16 -0
  15. data/lib/charming/database_installer.rb +27 -0
  16. data/lib/charming/focus.rb +58 -2
  17. data/lib/charming/generators/app_file_generator.rb +13 -0
  18. data/lib/charming/generators/app_generator.rb +123 -47
  19. data/lib/charming/generators/base.rb +26 -0
  20. data/lib/charming/generators/component_generator.rb +10 -10
  21. data/lib/charming/generators/controller_generator.rb +22 -11
  22. data/lib/charming/generators/model_generator.rb +38 -29
  23. data/lib/charming/generators/name.rb +10 -0
  24. data/lib/charming/generators/screen_generator.rb +78 -32
  25. data/lib/charming/generators/templates/app/Gemfile.template +5 -0
  26. data/lib/charming/generators/templates/app/README.md.template +9 -0
  27. data/lib/charming/generators/templates/app/Rakefile.template +3 -0
  28. data/lib/charming/generators/templates/app/application.template +13 -0
  29. data/lib/charming/generators/templates/app/application_controller.template +19 -0
  30. data/lib/charming/generators/templates/app/application_record.template +7 -0
  31. data/lib/charming/generators/templates/app/application_state.template +6 -0
  32. data/lib/charming/generators/templates/app/database_config.template +12 -0
  33. data/lib/charming/generators/templates/app/executable.template +7 -0
  34. data/lib/charming/generators/templates/app/gemspec.template +6 -0
  35. data/lib/charming/generators/templates/app/home_controller.template +6 -0
  36. data/lib/charming/generators/templates/app/home_state.template +7 -0
  37. data/lib/charming/generators/templates/app/keep.template +0 -0
  38. data/lib/charming/generators/templates/app/layout.template +113 -0
  39. data/lib/charming/generators/templates/app/root_file.template +20 -0
  40. data/lib/charming/generators/templates/app/routes.template +5 -0
  41. data/lib/charming/generators/templates/app/seeds.template +1 -0
  42. data/lib/charming/generators/templates/app/spec_controller.template +17 -0
  43. data/lib/charming/generators/templates/app/spec_helper.template +3 -0
  44. data/lib/charming/generators/templates/app/spec_state.template +17 -0
  45. data/lib/charming/generators/templates/app/spec_view.template +16 -0
  46. data/lib/charming/generators/templates/app/version.template +5 -0
  47. data/lib/charming/generators/templates/app/view.template +21 -0
  48. data/lib/charming/generators/templates/component/component.rb.template +9 -0
  49. data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
  50. data/lib/charming/generators/templates/model/migration.rb.template +9 -0
  51. data/lib/charming/generators/templates/model/model.rb.template +6 -0
  52. data/lib/charming/generators/templates/model/spec.rb.template +9 -0
  53. data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
  54. data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
  55. data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
  56. data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
  57. data/lib/charming/generators/templates/screen/state.rb.template +7 -0
  58. data/lib/charming/generators/templates/screen/view.rb.template +11 -0
  59. data/lib/charming/generators/templates/view/view.rb.template +11 -0
  60. data/lib/charming/generators/view_generator.rb +19 -3
  61. data/lib/charming/internal/renderer/differential.rb +15 -0
  62. data/lib/charming/internal/renderer/full_repaint.rb +6 -0
  63. data/lib/charming/internal/terminal/adapter.rb +29 -3
  64. data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
  65. data/lib/charming/internal/terminal/memory_backend.rb +28 -1
  66. data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
  67. data/lib/charming/internal/terminal/tty_backend.rb +43 -113
  68. data/lib/charming/presentation/components/empty_state.rb +13 -0
  69. data/lib/charming/presentation/components/form/builder.rb +14 -0
  70. data/lib/charming/presentation/components/form/confirm.rb +13 -0
  71. data/lib/charming/presentation/components/form/field.rb +25 -0
  72. data/lib/charming/presentation/components/form/input.rb +14 -0
  73. data/lib/charming/presentation/components/form/note.rb +9 -0
  74. data/lib/charming/presentation/components/form/select.rb +23 -0
  75. data/lib/charming/presentation/components/form/textarea.rb +16 -0
  76. data/lib/charming/presentation/components/form.rb +29 -0
  77. data/lib/charming/presentation/components/list.rb +28 -0
  78. data/lib/charming/presentation/components/markdown.rb +6 -0
  79. data/lib/charming/presentation/components/modal.rb +14 -0
  80. data/lib/charming/presentation/components/progressbar.rb +13 -0
  81. data/lib/charming/presentation/components/spinner.rb +10 -0
  82. data/lib/charming/presentation/components/table.rb +25 -0
  83. data/lib/charming/presentation/components/text_area.rb +48 -0
  84. data/lib/charming/presentation/components/text_input.rb +24 -0
  85. data/lib/charming/presentation/components/viewport.rb +52 -0
  86. data/lib/charming/presentation/layout/builder.rb +86 -0
  87. data/lib/charming/presentation/layout/overlay.rb +57 -0
  88. data/lib/charming/presentation/layout/pane.rb +145 -0
  89. data/lib/charming/presentation/layout/rect.rb +23 -0
  90. data/lib/charming/presentation/layout/screen_layout.rb +60 -0
  91. data/lib/charming/presentation/layout/split.rb +134 -0
  92. data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
  93. data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
  94. data/lib/charming/presentation/markdown/render_context.rb +22 -0
  95. data/lib/charming/presentation/markdown/renderer.rb +45 -135
  96. data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
  97. data/lib/charming/presentation/markdown.rb +3 -0
  98. data/lib/charming/presentation/template_view.rb +7 -0
  99. data/lib/charming/presentation/templates.rb +17 -0
  100. data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
  101. data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
  102. data/lib/charming/presentation/ui/border_painter.rb +58 -0
  103. data/lib/charming/presentation/ui/canvas.rb +82 -0
  104. data/lib/charming/presentation/ui/style.rb +62 -95
  105. data/lib/charming/presentation/ui.rb +15 -156
  106. data/lib/charming/presentation/view.rb +17 -0
  107. data/lib/charming/runtime.rb +2 -0
  108. data/lib/charming/tasks/inline_executor.rb +9 -0
  109. data/lib/charming/tasks/task.rb +3 -0
  110. data/lib/charming/tasks/threaded_executor.rb +12 -0
  111. data/lib/charming/version.rb +1 -1
  112. data/lib/charming.rb +13 -0
  113. metadata +59 -10
  114. data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
  115. data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
  116. data/lib/charming/generators/app_generator/component_templates.rb +0 -36
  117. data/lib/charming/generators/app_generator/controller_template.rb +0 -60
  118. data/lib/charming/generators/app_generator/database_templates.rb +0 -45
  119. data/lib/charming/generators/app_generator/layout_template.rb +0 -66
  120. data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
  121. data/lib/charming/generators/app_generator/state_templates.rb +0 -30
  122. data/lib/charming/generators/app_generator/view_template.rb +0 -84
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module Layouts
5
+ class ApplicationLayout < Charming::Presentation::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\np 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::Presentation::Components::Modal.new(
88
+ content: palette_component,
89
+ title: "Command palette",
90
+ help: "Type to filter. Enter selects. Escape closes.",
91
+ width: 52,
92
+ theme: theme
93
+ )
94
+ end
95
+
96
+ def sidebar_focused?
97
+ controller.sidebar_focused?
98
+ end
99
+
100
+ def content_focused?
101
+ controller.content_focused?
102
+ end
103
+
104
+ def sidebar_index
105
+ controller.sidebar_index
106
+ end
107
+
108
+ def current_route?(route)
109
+ controller.current_route?(route)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "charming"
4
+ require "zeitwerk"
5
+ __DATABASE_REQUIRE__
6
+
7
+ module __APP_CLASS__
8
+ end
9
+
10
+ loader = Zeitwerk::Loader.new
11
+ loader.tag = "__APP_SNAKE__"
12
+ loader.inflector.inflect("version" => "VERSION")
13
+ loader.push_dir(File.expand_path("__APP_SNAKE__", __dir__), namespace: __APP_CLASS__)
14
+ __MODEL_LOADER__loader.push_dir(File.expand_path("../app/state", __dir__), namespace: __APP_CLASS__)
15
+ loader.push_dir(File.expand_path("../app/components", __dir__), namespace: __APP_CLASS__)
16
+ loader.push_dir(File.expand_path("../app/views", __dir__), namespace: __APP_CLASS__)
17
+ loader.push_dir(File.expand_path("../app/controllers", __dir__), namespace: __APP_CLASS__)
18
+ loader.setup
19
+
20
+ require_relative "../config/routes"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ __APP_CLASS__::Application.routes do
4
+ root "home#show"
5
+ end
@@ -0,0 +1 @@
1
+ # frozen_string_literal: true
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module Home
5
+ class ShowView < Charming::Presentation::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 p for commands, q to quit.", style: theme.muted
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __COMPONENT_CLASS__ < Charming::Presentation::Component
5
+ def render
6
+ text "__RESOURCE_NAME__"
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __CONTROLLER_CLASS__ < ApplicationController
5
+ __ACTION_METHODS__ end
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Create__TABLE_CLASS__ < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :__TABLE_NAME__ do |t|
6
+ __FIELD_LINES__ t.timestamps
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __MODEL_CLASS__ < ApplicationRecord
5
+ end
6
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__MODEL_CLASS__ do
6
+ it "inherits from ApplicationRecord" do
7
+ expect(described_class.superclass).to eq(__APP_CLASS__::ApplicationRecord)
8
+ end
9
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __CONTROLLER_CLASS__ < ApplicationController
5
+ __CONTROLLER_BODY__
6
+ end
7
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__CONTROLLER_CLASS__ 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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__STATE_CLASS__ 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("__TITLE__")
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "__APP_SNAKE__"
4
+
5
+ RSpec.describe __APP_CLASS__::__RESOURCE_MODULE__::ShowView do
6
+ describe "#render" do
7
+ it "renders the state title" do
8
+ view = described_class.new(__SCREEN_NAME__: double(title: "__TITLE__"))
9
+
10
+ expect(view.render).to eq("__TITLE__")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ class __STATE_CLASS__ < ApplicationState
5
+ attribute :title, :string, default: "__TITLE__"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module __RESOURCE_MODULE__
5
+ class ShowView < Charming::Presentation::View
6
+ def render
7
+ __SCREEN_NAME__.title
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module __APP_CLASS__
4
+ module __RESOURCE_MODULE__
5
+ class __ACTION_VIEW_CLASS__View < Charming::Presentation::View
6
+ def render
7
+ "__RESOURCE_NAME__"
8
+ end
9
+ end
10
+ end
11
+ end
@@ -2,7 +2,10 @@
2
2
 
3
3
  module Charming
4
4
  module Generators
5
+ # ViewGenerator implements `charming generate view NAME [ACTION]`. Writes a single
6
+ # view class file at `app/views/<name>/<action>_view.rb`. The *action* defaults to `show`.
5
7
  class ViewGenerator < AppFileGenerator
8
+ # *name* is the resource name. *args* may contain a single action name (defaults to "show").
6
9
  def initialize(name, args, out:, destination:, force: false)
7
10
  super
8
11
  raise Error, "Usage: charming generate view NAME [ACTION]" if args.length > 1
@@ -10,21 +13,34 @@ module Charming
10
13
  @action = args.fetch(0, "show")
11
14
  end
12
15
 
16
+ # Writes the view file to `app/views/<name>/<action>_view.rb`.
13
17
  def generate
14
- create_file(File.join("app", "views", name.snake_name, "#{action}.tui.erb"), view)
18
+ create_file(File.join("app", "views", name.snake_name, "#{action}_view.rb"), view)
15
19
  end
16
20
 
17
21
  private
18
22
 
23
+ # The action name (e.g., "show", "edit").
19
24
  attr_reader :action
20
25
 
26
+ # The file-name suffix used by `app_path` (sets "view" so the file is
27
+ # `<name>_view.rb`). Not used directly by this generator but required by the parent.
21
28
  def suffix
22
29
  "view"
23
30
  end
24
31
 
32
+ # The full source of the generated view class.
25
33
  def view
26
- %(<%= "#{name.class_name}" %>
27
- )
34
+ render_template("view/view.rb.template",
35
+ app_class: app_name.class_name,
36
+ resource_module: name.class_name,
37
+ resource_name: name.class_name,
38
+ action_view_class: action_class_name)
39
+ end
40
+
41
+ # CamelCase rendering of the action name (e.g., "user_settings" → "UserSettings").
42
+ def action_class_name
43
+ action.split("_").map(&:capitalize).join
28
44
  end
29
45
  end
30
46
  end
@@ -3,13 +3,22 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Renderer
6
+ # Differential renders frame updates by emitting only the lines that changed since
7
+ # the previous frame. On the first frame it falls back to a full repaint; when no
8
+ # lines changed it returns without writing anything.
6
9
  class Differential
10
+ # *output* is the terminal backend (must support `write_lines` for the differential
11
+ # path or `write_frame` for the fallback). *full_renderer* is the FullRepaint used
12
+ # for the initial frame and as a fallback when *output* doesn't support partial writes.
7
13
  def initialize(output, full_renderer: FullRepaint.new(output))
8
14
  @output = output
9
15
  @full_renderer = full_renderer
10
16
  @previous_frame = nil
11
17
  end
12
18
 
19
+ # Renders *frame*. The first call performs a full repaint and stores the frame.
20
+ # Subsequent calls compute the per-line diff and emit only changed rows. Returns nil
21
+ # when the frame is identical to the previous one.
13
22
  def render(frame)
14
23
  frame = frame.to_s
15
24
  return render_initial(frame) unless @previous_frame
@@ -20,11 +29,15 @@ module Charming
20
29
 
21
30
  private
22
31
 
32
+ # Performs the initial full repaint and records the first frame.
23
33
  def render_initial(frame)
24
34
  @full_renderer.render(frame)
25
35
  @previous_frame = frame
26
36
  end
27
37
 
38
+ # Computes the per-line diff against the previous frame, writes the changed lines,
39
+ # and records the new frame. Falls back to a full repaint when the output backend
40
+ # doesn't support partial writes.
28
41
  def render_changes(frame)
29
42
  changes = changed_lines(@previous_frame, frame)
30
43
  return @previous_frame = frame if changes.empty?
@@ -37,6 +50,8 @@ module Charming
37
50
  @previous_frame = frame
38
51
  end
39
52
 
53
+ # Returns an array of [1-based-row, line] tuples covering the larger of the two
54
+ # frames' line counts, with empty strings padding the shorter frame.
40
55
  def changed_lines(previous_frame, frame)
41
56
  previous_lines = previous_frame.lines(chomp: true)
42
57
  lines = frame.lines(chomp: true)
@@ -3,11 +3,17 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Renderer
6
+ # FullRepaint clears the screen and rewrites the entire frame on every render. It is
7
+ # used as the initial render path by Differential and as a fallback for backends that
8
+ # don't support partial line writes.
6
9
  class FullRepaint
10
+ # *output* is the terminal backend (must support `clear`, `move_cursor`, and
11
+ # `write_frame` per the Adapter contract).
7
12
  def initialize(output)
8
13
  @output = output
9
14
  end
10
15
 
16
+ # Clears the screen, homes the cursor, and writes the entire *frame* string.
11
17
  def render(frame)
12
18
  @output.clear
13
19
  @output.move_cursor(1, 1)
@@ -3,46 +3,72 @@
3
3
  module Charming
4
4
  module Internal
5
5
  module Terminal
6
- # Contract for terminal adapters used by Runtime and renderers.
7
- # Concrete adapters provide input events, terminal dimensions, and output
8
- # primitives for full or partial frame rendering.
6
+ # Adapter defines the duck-typed interface that terminal backends must implement.
7
+ # Concrete adapters (`TTYBackend`, `MemoryBackend`) mix this module in and provide
8
+ # the actual implementations; the methods below raise NotImplementedError to make
9
+ # missing implementations fail loudly.
10
+ #
11
+ # Input methods:
12
+ # - `read_event(timeout:)` returns the next event (KeyEvent, MouseEvent, or nil on timeout)
13
+ #
14
+ # Output methods:
15
+ # - `size` returns the [width, height] of the terminal
16
+ # - `enter_alt_screen` / `leave_alt_screen` switch to/from the alternate screen buffer
17
+ # - `hide_cursor` / `show_cursor` toggle the cursor
18
+ # - `clear` clears the screen
19
+ # - `move_cursor(row, column)` positions the cursor (1-based)
20
+ # - `write_frame(frame)` writes a full multi-line frame string
21
+ # - `write_lines(line_changes, frame: nil)` writes a partial frame of [row, line] changes
9
22
  module Adapter
23
+ # Reads the next event from the backend. Returns nil when no event is available
24
+ # within *timeout* seconds. Must be implemented by the including class.
10
25
  def read_event(timeout: nil)
11
26
  raise NotImplementedError, "#{self.class} must implement #read_event"
12
27
  end
13
28
 
29
+ # Returns the current terminal dimensions as [width, height] in cells.
14
30
  def size
15
31
  raise NotImplementedError, "#{self.class} must implement #size"
16
32
  end
17
33
 
34
+ # Switches the terminal into the alternate screen buffer (used to keep the host
35
+ # terminal scrollback untouched while a TUI app is running).
18
36
  def enter_alt_screen
19
37
  raise NotImplementedError, "#{self.class} must implement #enter_alt_screen"
20
38
  end
21
39
 
40
+ # Returns the terminal to the primary screen buffer (paired with `enter_alt_screen`).
22
41
  def leave_alt_screen
23
42
  raise NotImplementedError, "#{self.class} must implement #leave_alt_screen"
24
43
  end
25
44
 
45
+ # Hides the terminal cursor.
26
46
  def hide_cursor
27
47
  raise NotImplementedError, "#{self.class} must implement #hide_cursor"
28
48
  end
29
49
 
50
+ # Shows the terminal cursor.
30
51
  def show_cursor
31
52
  raise NotImplementedError, "#{self.class} must implement #show_cursor"
32
53
  end
33
54
 
55
+ # Clears the entire screen and homes the cursor.
34
56
  def clear
35
57
  raise NotImplementedError, "#{self.class} must implement #clear"
36
58
  end
37
59
 
60
+ # Moves the cursor to the given 1-based (row, column) position.
38
61
  def move_cursor(row, column)
39
62
  raise NotImplementedError, "#{self.class} must implement #move_cursor"
40
63
  end
41
64
 
65
+ # Writes a full multi-line frame string to the terminal in one operation.
42
66
  def write_frame(frame)
43
67
  raise NotImplementedError, "#{self.class} must implement #write_frame"
44
68
  end
45
69
 
70
+ # Writes a partial frame composed of [row, line] tuples. Optional *frame:* is the
71
+ # full frame string for backends that want to track it (e.g., the MemoryBackend).
46
72
  def write_lines(line_changes, frame: nil)
47
73
  raise NotImplementedError, "#{self.class} must implement #write_lines"
48
74
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Internal
5
+ module Terminal
6
+ # KeyNormalizer turns raw keypress strings (from tty-reader) into normalized
7
+ # KeyEvent objects with a semantic key symbol and the printable character (when
8
+ # applicable). Handles ctrl-modifier naming, special keys (return, tab, space),
9
+ # and the back-tab (Shift+Tab) variant.
10
+ class KeyNormalizer
11
+ # Matches key names like "ctrl_a" → captures "a" so the modifier can be split out.
12
+ CTRL_KEY_PATTERN = /\Actrl_(?<key>.+)\z/
13
+
14
+ # *reader* is a TTY::Reader used to look up canonical key names from raw keypresses.
15
+ def initialize(reader)
16
+ @reader = reader
17
+ end
18
+
19
+ # Converts a raw *keypress* string into a KeyEvent. Returns nil when *keypress* is nil.
20
+ def normalize(keypress)
21
+ return nil unless keypress
22
+
23
+ key_name = @reader.console.keys[keypress]
24
+ return character_event(keypress) unless key_name
25
+
26
+ named_event(key_name)
27
+ end
28
+
29
+ private
30
+
31
+ # Builds a KeyEvent for a raw character keypress (no semantic name was matched).
32
+ def character_event(keypress)
33
+ Events::KeyEvent.new(key: keypress.to_sym, char: keypress)
34
+ end
35
+
36
+ # Builds a KeyEvent for a named key, splitting out modifiers and the printable char.
37
+ def named_event(key_name)
38
+ normalized = normalize_key_name(key_name)
39
+ Events::KeyEvent.new(
40
+ key: normalized.fetch(:key),
41
+ char: normalized.fetch(:char, nil),
42
+ ctrl: normalized.fetch(:ctrl, false),
43
+ alt: normalized.fetch(:alt, false),
44
+ shift: normalized.fetch(:shift, false)
45
+ )
46
+ end
47
+
48
+ # Splits a key name into its semantic key, printable char, and modifier flags.
49
+ def normalize_key_name(key_name)
50
+ name = key_name.to_s
51
+ return ctrl_key(name) if name.match?(CTRL_KEY_PATTERN)
52
+ return {key: :tab, shift: true} if name == "back_tab"
53
+
54
+ {key: normalized_key(name), char: printable_char(name)}
55
+ end
56
+
57
+ # Returns the semantic key symbol for *name* (e.g., "return" → :enter).
58
+ def normalized_key(name)
59
+ return :enter if name == "return"
60
+
61
+ name.to_sym
62
+ end
63
+
64
+ # Returns a key/ctrl-modifier pair for a `ctrl_*` key name.
65
+ def ctrl_key(name)
66
+ match = name.match(CTRL_KEY_PATTERN)
67
+ {key: match[:key].to_sym, ctrl: true}
68
+ end
69
+
70
+ # Returns the printable character for *name* (e.g., "space" → " "), or nil when the
71
+ # name has no single-character printable form.
72
+ def printable_char(name)
73
+ case name
74
+ when "space" then " "
75
+ when "enter", "return" then "\n"
76
+ when "tab" then "\t"
77
+ else
78
+ name if name.length == 1 && !name.match?(/[[:cntrl:]]/)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end