charming 0.1.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +421 -0
  4. data/exe/charming +6 -0
  5. data/lib/charming/application.rb +90 -0
  6. data/lib/charming/application_model.rb +13 -0
  7. data/lib/charming/cli.rb +60 -0
  8. data/lib/charming/component.rb +8 -0
  9. data/lib/charming/components/activity_indicator.rb +158 -0
  10. data/lib/charming/components/command_palette.rb +118 -0
  11. data/lib/charming/components/keyboard_handler.rb +22 -0
  12. data/lib/charming/components/list.rb +105 -0
  13. data/lib/charming/components/modal.rb +48 -0
  14. data/lib/charming/components/progressbar.rb +55 -0
  15. data/lib/charming/components/spinner.rb +37 -0
  16. data/lib/charming/components/table.rb +115 -0
  17. data/lib/charming/components/text_input.rb +103 -0
  18. data/lib/charming/components/viewport.rb +191 -0
  19. data/lib/charming/controller.rb +523 -0
  20. data/lib/charming/focus.rb +65 -0
  21. data/lib/charming/generators/app_file_generator.rb +28 -0
  22. data/lib/charming/generators/app_generator/app_spec_templates.rb +86 -0
  23. data/lib/charming/generators/app_generator/basic_templates.rb +69 -0
  24. data/lib/charming/generators/app_generator/component_templates.rb +36 -0
  25. data/lib/charming/generators/app_generator/controller_template.rb +69 -0
  26. data/lib/charming/generators/app_generator/layout_template.rb +160 -0
  27. data/lib/charming/generators/app_generator/model_templates.rb +30 -0
  28. data/lib/charming/generators/app_generator/screen_spec_templates.rb +70 -0
  29. data/lib/charming/generators/app_generator/view_template.rb +90 -0
  30. data/lib/charming/generators/app_generator.rb +76 -0
  31. data/lib/charming/generators/base.rb +29 -0
  32. data/lib/charming/generators/component_generator.rb +30 -0
  33. data/lib/charming/generators/controller_generator.rb +50 -0
  34. data/lib/charming/generators/name.rb +32 -0
  35. data/lib/charming/generators/screen_generator.rb +154 -0
  36. data/lib/charming/generators/view_generator.rb +34 -0
  37. data/lib/charming/generators.rb +7 -0
  38. data/lib/charming/internal/renderer/differential.rb +53 -0
  39. data/lib/charming/internal/renderer/full_repaint.rb +19 -0
  40. data/lib/charming/internal/terminal/adapter.rb +52 -0
  41. data/lib/charming/internal/terminal/memory_backend.rb +91 -0
  42. data/lib/charming/internal/terminal/tty_backend.rb +250 -0
  43. data/lib/charming/key_event.rb +13 -0
  44. data/lib/charming/mouse_event.rb +40 -0
  45. data/lib/charming/resize_event.rb +7 -0
  46. data/lib/charming/response.rb +33 -0
  47. data/lib/charming/router.rb +137 -0
  48. data/lib/charming/runtime.rb +192 -0
  49. data/lib/charming/screen.rb +8 -0
  50. data/lib/charming/task.rb +7 -0
  51. data/lib/charming/task_event.rb +17 -0
  52. data/lib/charming/task_executor.rb +62 -0
  53. data/lib/charming/timer_event.rb +7 -0
  54. data/lib/charming/ui/border.rb +33 -0
  55. data/lib/charming/ui/style.rb +244 -0
  56. data/lib/charming/ui/theme.rb +178 -0
  57. data/lib/charming/ui/themes/phosphor.json +100 -0
  58. data/lib/charming/ui/width.rb +24 -0
  59. data/lib/charming/ui.rb +230 -0
  60. data/lib/charming/version.rb +5 -0
  61. data/lib/charming/view.rb +116 -0
  62. data/lib/charming.rb +24 -0
  63. data/sig/charming.rbs +3 -0
  64. metadata +225 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module BasicTemplates
7
+ def gemfile
8
+ %(# frozen_string_literal: true
9
+
10
+ source "https://rubygems.org"
11
+
12
+ gemspec
13
+ )
14
+ end
15
+
16
+ def rakefile
17
+ %(# frozen_string_literal: true
18
+
19
+ require "bundler/gem_tasks"
20
+ )
21
+ end
22
+
23
+ def readme
24
+ %(# #{name.class_name}
25
+
26
+ A Charming terminal user interface.
27
+
28
+ Run it with:
29
+
30
+ ```sh
31
+ bundle exec #{name.snake_name}
32
+ ```
33
+ )
34
+ end
35
+
36
+ def gemspec
37
+ %(# frozen_string_literal: true
38
+
39
+ require_relative "lib/#{name.snake_name}/version"
40
+
41
+ Gem::Specification.new do |spec|
42
+ #{gemspec_attributes}
43
+ #{gemspec_dependencies}
44
+ end
45
+ )
46
+ end
47
+
48
+ def gemspec_attributes
49
+ %( spec.name = "#{name.snake_name}"
50
+ spec.version = #{name.class_name}::VERSION
51
+ spec.summary = "A Charming terminal user interface."
52
+ spec.authors = ["TODO: Your name"]
53
+ spec.email = ["TODO: Your email"]
54
+ spec.files = Dir.glob("{app,config,exe,lib}/**/*") + %w[README.md]
55
+ spec.bindir = "exe"
56
+ spec.executables = ["#{name.snake_name}"]
57
+ spec.require_paths = ["lib"]
58
+ spec.required_ruby_version = ">= 4.0.0"
59
+ spec.metadata["rubygems_mfa_required"] = "true")
60
+ end
61
+
62
+ def gemspec_dependencies
63
+ %(
64
+ spec.add_dependency "charming")
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module ComponentTemplates
7
+ def component
8
+ %(# frozen_string_literal: true
9
+
10
+ module #{name.class_name}
11
+ class AppFrameComponent < Charming::Component
12
+ def render
13
+ column(title_line, help_line, gap: 1)
14
+ end
15
+ #{component_helpers}
16
+ end
17
+ end
18
+ )
19
+ end
20
+
21
+ def component_helpers
22
+ %(
23
+ private
24
+
25
+ def title_line
26
+ text title, style: theme.title
27
+ end
28
+
29
+ def help_line
30
+ text "Press p for commands, q to quit.", style: theme.muted
31
+ end)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module ControllerTemplate
7
+ def application_controller
8
+ %(# frozen_string_literal: true
9
+
10
+ module #{name.class_name}
11
+ class ApplicationController < Charming::Controller
12
+ layout Layouts::Application
13
+ focus_ring :sidebar, :content
14
+
15
+ key "p", :open_command_palette, scope: :global
16
+ key "q", :quit, scope: :global
17
+
18
+ command "Home" do
19
+ navigate_to "/"
20
+ end
21
+
22
+ command "Theme", :open_theme_palette
23
+ command "Close palette", :close_command_palette
24
+ command "Quit app", :quit
25
+ end
26
+ end
27
+ )
28
+ end
29
+
30
+ def controller
31
+ %(# frozen_string_literal: true
32
+
33
+ module #{name.class_name}
34
+ class HomeController < ApplicationController
35
+ #{controller_actions}
36
+ #{controller_helpers}
37
+ end
38
+ end
39
+ )
40
+ end
41
+
42
+ def controller_actions
43
+ %(
44
+ def show
45
+ render_home
46
+ end)
47
+ end
48
+
49
+ def controller_helpers
50
+ %(
51
+
52
+ private
53
+ #{render_helpers})
54
+ end
55
+
56
+ def render_helpers
57
+ %(
58
+ def render_home
59
+ render HomeView.new(home: home, palette: command_palette, screen: screen, theme: theme)
60
+ end
61
+
62
+ def home
63
+ model(:home, HomeModel)
64
+ end)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module LayoutTemplate
7
+ def layout
8
+ %(# frozen_string_literal: true
9
+
10
+ module #{name.class_name}
11
+ module Layouts
12
+ class Application < Charming::View
13
+ def render
14
+ body = Charming::UI.place(app_frame, width: screen.width, height: screen.height)
15
+ return body unless palette
16
+
17
+ Charming::UI.overlay(body, command_palette_modal)
18
+ end
19
+ #{layout_helpers}
20
+ end
21
+ end
22
+ end
23
+ )
24
+ end
25
+
26
+ def layout_helpers
27
+ %(
28
+ private
29
+ #{layout_frame_helpers}
30
+ #{layout_navigation_helpers}
31
+ #{layout_modal_helpers}
32
+ #{layout_style_helpers})
33
+ end
34
+
35
+ def layout_frame_helpers
36
+ %(
37
+ def app_frame
38
+ narrow? ? column(sidebar, main_content, gap: 1) : row(sidebar, main_content, gap: 1)
39
+ end
40
+
41
+ def sidebar
42
+ box(column(app_title, navigation, shortcuts, gap: 1), style: sidebar_style)
43
+ end
44
+
45
+ def main_content
46
+ box(yield_content, style: main_content_style)
47
+ end)
48
+ end
49
+
50
+ def layout_navigation_helpers
51
+ %(
52
+
53
+ def app_title
54
+ text "#{name.class_name}", style: theme.header_accent.align(:center).width(sidebar_width)
55
+ end
56
+
57
+ def navigation
58
+ column(*nav_items)
59
+ end
60
+
61
+ def nav_items
62
+ controller.application.routes.all.each_with_index.map do |route, index|
63
+ text nav_label(route, index), style: nav_style(route, index)
64
+ end
65
+ end
66
+
67
+ def nav_label(route, index)
68
+ cursor = sidebar_focused? && index == sidebar_index ? ">" : " "
69
+ active = current_route?(route) ? "●" : " "
70
+ "\#{cursor} \#{active} \#{route.title}"
71
+ end
72
+
73
+ def nav_style(route, index)
74
+ return theme.selected if sidebar_focused? && index == sidebar_index
75
+ return theme.title if current_route?(route)
76
+
77
+ theme.muted
78
+ end
79
+
80
+ def shortcuts
81
+ text "tab focus\\np commands\\nq quit", style: theme.muted
82
+ end
83
+
84
+ def sidebar_focused?
85
+ controller.sidebar_focused?
86
+ end
87
+
88
+ def content_focused?
89
+ controller.content_focused?
90
+ end
91
+
92
+ def sidebar_index
93
+ controller.sidebar_index
94
+ end
95
+
96
+ def current_route?(route)
97
+ route.controller_class == controller.class && route.action == :show
98
+ end)
99
+ end
100
+
101
+ def layout_modal_helpers
102
+ %(
103
+
104
+ def command_palette_modal
105
+ render_component Charming::Components::Modal.new(
106
+ content: palette,
107
+ title: "Command palette",
108
+ help: "Type to filter. Enter selects. Escape closes.",
109
+ width: 52,
110
+ theme: theme
111
+ )
112
+ end)
113
+ end
114
+
115
+ def layout_style_helpers
116
+ %(
117
+ #{layout_frame_style_helpers}
118
+ #{layout_dimension_helpers})
119
+ end
120
+
121
+ def layout_frame_style_helpers
122
+ %(
123
+ def sidebar_style
124
+ base = sidebar_focused? ? theme.title : theme.border
125
+ base = base.border(:rounded).padding(1, 2).width(sidebar_width).height(panel_height)
126
+ palette ? base.faint : base
127
+ end
128
+
129
+ def main_content_style
130
+ base = content_focused? ? theme.title : theme.border
131
+ base = base.border(:rounded).padding(1, 2).width(main_content_width).height(panel_height)
132
+ palette ? base.faint : base
133
+ end)
134
+ end
135
+
136
+ def layout_dimension_helpers
137
+ %(
138
+
139
+ def narrow?
140
+ screen.width < 72 && screen.height >= 20
141
+ end
142
+
143
+ def sidebar_width
144
+ narrow? ? [screen.width - 6, 20].max : 22
145
+ end
146
+
147
+ def main_content_width
148
+ narrow? ? [screen.width - 6, 20].max : [screen.width - sidebar_width - 13, 20].max
149
+ end
150
+
151
+ def panel_height
152
+ return nil if narrow?
153
+
154
+ [screen.height - 4, 5].max
155
+ end)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module ModelTemplates
7
+ def application_model
8
+ %(# frozen_string_literal: true
9
+
10
+ module #{name.class_name}
11
+ class ApplicationModel < Charming::ApplicationModel
12
+ end
13
+ end
14
+ )
15
+ end
16
+
17
+ def home_model
18
+ %(# frozen_string_literal: true
19
+
20
+ module #{name.class_name}
21
+ class HomeModel < ApplicationModel
22
+ attribute :title, :string, default: "#{name.class_name}"
23
+ end
24
+ end
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module ScreenSpecTemplates
7
+ def spec_model
8
+ %(# frozen_string_literal: true
9
+
10
+ require "#{app_name.snake_name}"
11
+
12
+ RSpec.describe #{app_name.class_name}::#{name.class_name}Model do
13
+ describe "#title" do
14
+ it "has the correct default string value" do
15
+ instance = described_class.new
16
+ expect(instance.title).to eq("#{name.class_name}")
17
+ end
18
+
19
+ it "accepts overridden title values" do
20
+ instance = described_class.new(title: "Alternative")
21
+ expect(instance.title).to eq("Alternative")
22
+ end
23
+ end
24
+ end
25
+ )
26
+ end
27
+
28
+ def spec_controller
29
+ %(# frozen_string_literal: true
30
+
31
+ require "#{app_name.snake_name}"
32
+
33
+ RSpec.describe #{app_name.class_name}::#{name.controller_class_name} do
34
+ let(:application) { #{app_name.class_name}::Application.new }
35
+
36
+ subject(:controller) { described_class.new(application: application) }
37
+
38
+ describe "#show" do
39
+ it "renders the view with the model" do
40
+ response = controller.dispatch(:show)
41
+
42
+ expect(response).to respond_to(:body)
43
+ end
44
+ end
45
+ end
46
+ )
47
+ end
48
+
49
+ def spec_view
50
+ %(# frozen_string_literal: true
51
+
52
+ require "#{app_name.snake_name}"
53
+
54
+ RSpec.describe #{app_name.class_name}::#{name.view_class_name} do
55
+ describe "#render" do
56
+ it "renders the model title" do
57
+ view = described_class.new(
58
+ #{name.snake_name}: double(title: "#{name.class_name}")
59
+ )
60
+
61
+ expect(view.render).to eq("#{name.class_name}")
62
+ end
63
+ end
64
+ end
65
+ )
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator
6
+ module ViewTemplate
7
+ def executable
8
+ %(#!/usr/bin/env ruby
9
+ # frozen_string_literal: true
10
+
11
+ require "bundler/setup"
12
+ require "#{name.snake_name}"
13
+
14
+ Charming.run(#{name.class_name}::Application.new)
15
+ )
16
+ end
17
+
18
+ def root_file
19
+ %(# frozen_string_literal: true
20
+
21
+ require "charming"
22
+ require "zeitwerk"
23
+
24
+ module #{name.class_name}
25
+ end
26
+
27
+ loader = Zeitwerk::Loader.new
28
+ loader.tag = "#{name.snake_name}"
29
+ loader.inflector.inflect("version" => "VERSION")
30
+ loader.push_dir(File.expand_path("#{name.snake_name}", __dir__), namespace: #{name.class_name})
31
+ loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{name.class_name})
32
+ loader.push_dir(File.expand_path("../app/components", __dir__), namespace: #{name.class_name})
33
+ loader.push_dir(File.expand_path("../app/views", __dir__), namespace: #{name.class_name})
34
+ loader.push_dir(File.expand_path("../app/controllers", __dir__), namespace: #{name.class_name})
35
+ loader.setup
36
+
37
+ require_relative "../config/routes"
38
+ )
39
+ end
40
+
41
+ def application
42
+ %(# frozen_string_literal: true
43
+
44
+ module #{name.class_name}
45
+ class Application < Charming::Application
46
+ Charming::UI::Theme.built_in_names.each do |theme_name|
47
+ theme theme_name.to_sym, built_in: theme_name
48
+ end
49
+
50
+ default_theme :phosphor
51
+ end
52
+ end
53
+ )
54
+ end
55
+
56
+ def version
57
+ %(# frozen_string_literal: true
58
+
59
+ module #{name.class_name}
60
+ VERSION = "0.1.0"
61
+ end
62
+ )
63
+ end
64
+
65
+ def view
66
+ %(# frozen_string_literal: true
67
+
68
+ module #{name.class_name}
69
+ class HomeView < Charming::View
70
+ def render
71
+ app_frame
72
+ end
73
+ #{view_helpers}
74
+ end
75
+ end
76
+ )
77
+ end
78
+
79
+ def view_helpers
80
+ %(
81
+ private
82
+
83
+ def app_frame
84
+ render_component AppFrameComponent.new(title: home.title, theme: theme)
85
+ end)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class AppGenerator < Base
6
+ include BasicTemplates
7
+ include ComponentTemplates
8
+ include ControllerTemplate
9
+ include LayoutTemplate
10
+ include ModelTemplates
11
+ include ScreenSpecTemplates
12
+ include ViewTemplate
13
+ include AppSpecTemplates
14
+
15
+ FILE_TEMPLATES = [
16
+ ["Gemfile", :gemfile],
17
+ ["Rakefile", :rakefile],
18
+ ["README.md", :readme],
19
+ ["%<name>s.gemspec", :gemspec],
20
+ ["exe/%<name>s", :executable],
21
+ ["lib/%<name>s.rb", :root_file],
22
+ ["lib/%<name>s/application.rb", :application],
23
+ ["lib/%<name>s/version.rb", :version],
24
+ ["config/routes.rb", :routes],
25
+ ["app/models/application_model.rb", :application_model],
26
+ ["app/models/home_model.rb", :home_model],
27
+ ["app/controllers/application_controller.rb", :application_controller],
28
+ ["app/controllers/home_controller.rb", :controller],
29
+ ["app/views/layouts/application.rb", :layout],
30
+ ["app/views/home_view.rb", :view],
31
+ ["app/components/app_frame_component.rb", :component],
32
+ ["spec/spec_helper.rb", :spec_helper],
33
+ ["spec/models/home_model_spec.rb", :spec_model],
34
+ ["spec/controllers/home_controller_spec.rb", :spec_controller],
35
+ ["spec/views/home_view_spec.rb", :spec_view],
36
+ ["spec/components/app_frame_component_spec.rb", :spec_component]
37
+ ].freeze
38
+
39
+ def initialize(name, out:, destination:, force: false)
40
+ super(out: out, destination: File.join(destination, name), force: force)
41
+ @name = Name.new(name)
42
+ end
43
+
44
+ def generate
45
+ FILE_TEMPLATES.each do |path, template|
46
+ create_file(file_path(path), send(template), executable: template == :executable)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :name
53
+ alias_method :app_name, :name
54
+
55
+ def file_path(path)
56
+ format(path, name: name.snake_name)
57
+ end
58
+
59
+ def routes
60
+ %(# frozen_string_literal: true
61
+
62
+ #{name.class_name}::Application.routes do
63
+ root "home#show"
64
+ end
65
+ )
66
+ end
67
+
68
+ def spec_helper
69
+ %(# frozen_string_literal: true
70
+
71
+ require "#{name.snake_name}"
72
+ )
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Charming
6
+ module Generators
7
+ class Base
8
+ def initialize(out:, destination:, force: false)
9
+ @out = out
10
+ @destination = destination
11
+ @force = force
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :out, :destination
17
+
18
+ def create_file(path, content, executable: false)
19
+ absolute_path = File.join(destination, path)
20
+ raise Error, "File already exists: #{path}" if File.exist?(absolute_path) && !@force
21
+
22
+ FileUtils.mkdir_p(File.dirname(absolute_path))
23
+ File.write(absolute_path, content)
24
+ FileUtils.chmod("u+x,go+rx", absolute_path) if executable
25
+ out.puts "create #{path}"
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class ComponentGenerator < AppFileGenerator
6
+ def generate
7
+ create_file(app_path("app", "components"), component)
8
+ end
9
+
10
+ private
11
+
12
+ def suffix
13
+ "component"
14
+ end
15
+
16
+ def component
17
+ %(# frozen_string_literal: true
18
+
19
+ module #{app_name.class_name}
20
+ class #{name.component_class_name} < Charming::Component
21
+ def render
22
+ text "#{name.class_name}"
23
+ end
24
+ end
25
+ end
26
+ )
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Charming
4
+ module Generators
5
+ class ControllerGenerator < AppFileGenerator
6
+ def initialize(name, args, out:, destination:, force: false)
7
+ super
8
+ @actions = args
9
+ end
10
+
11
+ def generate
12
+ create_file(app_path("app", "controllers"), controller)
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :actions
18
+
19
+ def suffix
20
+ "controller"
21
+ end
22
+
23
+ def controller
24
+ %(# frozen_string_literal: true
25
+
26
+ module #{app_name.class_name}
27
+ class #{name.controller_class_name} < ApplicationController
28
+ #{action_methods} end
29
+ end
30
+ )
31
+ end
32
+
33
+ def action_methods
34
+ return action_method("show") if actions.empty?
35
+
36
+ actions.map { |action| action_method(action) }.join("\n")
37
+ end
38
+
39
+ def action_method(action)
40
+ %( def #{action}
41
+ render #{name.view_class_name}.new(
42
+ palette: command_palette,
43
+ screen: screen
44
+ )
45
+ end
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end