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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/charming/application.rb +11 -0
- data/lib/charming/cli.rb +23 -0
- data/lib/charming/controller/class_methods.rb +115 -0
- data/lib/charming/controller/command_palette.rb +135 -0
- data/lib/charming/controller/component_dispatching.rb +81 -0
- data/lib/charming/controller/dispatching.rb +60 -0
- data/lib/charming/controller/focus_management.rb +30 -0
- data/lib/charming/controller/rendering.rb +127 -0
- data/lib/charming/controller/session_state.rb +41 -0
- data/lib/charming/controller/sidebar_navigation.rb +111 -0
- data/lib/charming/controller.rb +35 -559
- data/lib/charming/database_commands.rb +16 -0
- data/lib/charming/database_installer.rb +27 -0
- data/lib/charming/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +123 -47
- data/lib/charming/generators/base.rb +26 -0
- data/lib/charming/generators/component_generator.rb +10 -10
- data/lib/charming/generators/controller_generator.rb +22 -11
- data/lib/charming/generators/model_generator.rb +38 -29
- data/lib/charming/generators/name.rb +10 -0
- data/lib/charming/generators/screen_generator.rb +78 -32
- data/lib/charming/generators/templates/app/Gemfile.template +5 -0
- data/lib/charming/generators/templates/app/README.md.template +9 -0
- data/lib/charming/generators/templates/app/Rakefile.template +3 -0
- data/lib/charming/generators/templates/app/application.template +13 -0
- data/lib/charming/generators/templates/app/application_controller.template +19 -0
- data/lib/charming/generators/templates/app/application_record.template +7 -0
- data/lib/charming/generators/templates/app/application_state.template +6 -0
- data/lib/charming/generators/templates/app/database_config.template +12 -0
- data/lib/charming/generators/templates/app/executable.template +7 -0
- data/lib/charming/generators/templates/app/gemspec.template +6 -0
- data/lib/charming/generators/templates/app/home_controller.template +6 -0
- data/lib/charming/generators/templates/app/home_state.template +7 -0
- data/lib/charming/generators/templates/app/keep.template +0 -0
- data/lib/charming/generators/templates/app/layout.template +113 -0
- data/lib/charming/generators/templates/app/root_file.template +20 -0
- data/lib/charming/generators/templates/app/routes.template +5 -0
- data/lib/charming/generators/templates/app/seeds.template +1 -0
- data/lib/charming/generators/templates/app/spec_controller.template +17 -0
- data/lib/charming/generators/templates/app/spec_helper.template +3 -0
- data/lib/charming/generators/templates/app/spec_state.template +17 -0
- data/lib/charming/generators/templates/app/spec_view.template +16 -0
- data/lib/charming/generators/templates/app/version.template +5 -0
- data/lib/charming/generators/templates/app/view.template +21 -0
- data/lib/charming/generators/templates/component/component.rb.template +9 -0
- data/lib/charming/generators/templates/controller/controller.rb.template +6 -0
- data/lib/charming/generators/templates/model/migration.rb.template +9 -0
- data/lib/charming/generators/templates/model/model.rb.template +6 -0
- data/lib/charming/generators/templates/model/spec.rb.template +9 -0
- data/lib/charming/generators/templates/screen/controller.rb.template +7 -0
- data/lib/charming/generators/templates/screen/spec_controller.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_state.rb.template +17 -0
- data/lib/charming/generators/templates/screen/spec_view.rb.template +13 -0
- data/lib/charming/generators/templates/screen/state.rb.template +7 -0
- data/lib/charming/generators/templates/screen/view.rb.template +11 -0
- data/lib/charming/generators/templates/view/view.rb.template +11 -0
- data/lib/charming/generators/view_generator.rb +19 -3
- data/lib/charming/internal/renderer/differential.rb +15 -0
- data/lib/charming/internal/renderer/full_repaint.rb +6 -0
- data/lib/charming/internal/terminal/adapter.rb +29 -3
- data/lib/charming/internal/terminal/key_normalizer.rb +84 -0
- data/lib/charming/internal/terminal/memory_backend.rb +28 -1
- data/lib/charming/internal/terminal/mouse_parser.rb +81 -0
- data/lib/charming/internal/terminal/tty_backend.rb +43 -113
- data/lib/charming/presentation/components/empty_state.rb +13 -0
- data/lib/charming/presentation/components/form/builder.rb +14 -0
- data/lib/charming/presentation/components/form/confirm.rb +13 -0
- data/lib/charming/presentation/components/form/field.rb +25 -0
- data/lib/charming/presentation/components/form/input.rb +14 -0
- data/lib/charming/presentation/components/form/note.rb +9 -0
- data/lib/charming/presentation/components/form/select.rb +23 -0
- data/lib/charming/presentation/components/form/textarea.rb +16 -0
- data/lib/charming/presentation/components/form.rb +29 -0
- data/lib/charming/presentation/components/list.rb +28 -0
- data/lib/charming/presentation/components/markdown.rb +6 -0
- data/lib/charming/presentation/components/modal.rb +14 -0
- data/lib/charming/presentation/components/progressbar.rb +13 -0
- data/lib/charming/presentation/components/spinner.rb +10 -0
- data/lib/charming/presentation/components/table.rb +25 -0
- data/lib/charming/presentation/components/text_area.rb +48 -0
- data/lib/charming/presentation/components/text_input.rb +24 -0
- data/lib/charming/presentation/components/viewport.rb +52 -0
- data/lib/charming/presentation/layout/builder.rb +86 -0
- data/lib/charming/presentation/layout/overlay.rb +57 -0
- data/lib/charming/presentation/layout/pane.rb +145 -0
- data/lib/charming/presentation/layout/rect.rb +23 -0
- data/lib/charming/presentation/layout/screen_layout.rb +60 -0
- data/lib/charming/presentation/layout/split.rb +134 -0
- data/lib/charming/presentation/markdown/block_renderers.rb +120 -0
- data/lib/charming/presentation/markdown/inline_renderers.rb +68 -0
- data/lib/charming/presentation/markdown/render_context.rb +22 -0
- data/lib/charming/presentation/markdown/renderer.rb +45 -135
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +16 -0
- data/lib/charming/presentation/markdown.rb +3 -0
- data/lib/charming/presentation/template_view.rb +7 -0
- data/lib/charming/presentation/templates.rb +17 -0
- data/lib/charming/presentation/ui/ansi_codes.rb +89 -0
- data/lib/charming/presentation/ui/ansi_slicer.rb +94 -0
- data/lib/charming/presentation/ui/border_painter.rb +58 -0
- data/lib/charming/presentation/ui/canvas.rb +82 -0
- data/lib/charming/presentation/ui/style.rb +62 -95
- data/lib/charming/presentation/ui.rb +15 -156
- data/lib/charming/presentation/view.rb +17 -0
- data/lib/charming/runtime.rb +2 -0
- data/lib/charming/tasks/inline_executor.rb +9 -0
- data/lib/charming/tasks/task.rb +3 -0
- data/lib/charming/tasks/threaded_executor.rb +12 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +13 -0
- metadata +59 -10
- data/lib/charming/generators/app_generator/app_spec_templates.rb +0 -90
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -81
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -60
- data/lib/charming/generators/app_generator/database_templates.rb +0 -45
- data/lib/charming/generators/app_generator/layout_template.rb +0 -66
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -69
- data/lib/charming/generators/app_generator/state_templates.rb +0 -30
- 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 @@
|
|
|
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,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,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,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
|
|
@@ -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}.
|
|
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
|
-
|
|
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
|
-
#
|
|
7
|
-
# Concrete adapters
|
|
8
|
-
#
|
|
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
|