charming 0.1.3 → 0.1.4

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +19 -2
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/component_dispatching.rb +47 -3
  5. data/lib/charming/controller/focus.rb +123 -0
  6. data/lib/charming/controller/focus_management.rb +1 -1
  7. data/lib/charming/controller/rendering.rb +4 -15
  8. data/lib/charming/controller/session_state.rb +11 -0
  9. data/lib/charming/controller.rb +11 -2
  10. data/lib/charming/database/commands.rb +106 -0
  11. data/lib/charming/generators/database_installer.rb +154 -0
  12. data/lib/charming/generators/model_generator.rb +2 -10
  13. data/lib/charming/generators/name.rb +1 -1
  14. data/lib/charming/generators/view_generator.rb +1 -1
  15. data/lib/charming/presentation/components/form/field.rb +1 -1
  16. data/lib/charming/presentation/components/markdown.rb +7 -7
  17. data/lib/charming/presentation/layout/pane.rb +7 -0
  18. data/lib/charming/presentation/layout/rect.rb +5 -0
  19. data/lib/charming/presentation/layout/screen_layout.rb +7 -0
  20. data/lib/charming/presentation/layout/split.rb +7 -0
  21. data/lib/charming/presentation/markdown/render_context.rb +28 -10
  22. data/lib/charming/presentation/markdown/renderer.rb +264 -39
  23. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  24. data/lib/charming/presentation/markdown/syntax_highlighter.rb +3 -2
  25. data/lib/charming/presentation/markdown.rb +2 -2
  26. data/lib/charming/presentation/view.rb +7 -0
  27. data/lib/charming/router.rb +3 -8
  28. data/lib/charming/runtime.rb +2 -0
  29. data/lib/charming/version.rb +1 -1
  30. data/lib/charming.rb +2 -2
  31. metadata +42 -9
  32. data/lib/charming/database_commands.rb +0 -103
  33. data/lib/charming/database_installer.rb +0 -152
  34. data/lib/charming/focus.rb +0 -121
  35. data/lib/charming/presentation/markdown/block_renderers.rb +0 -118
  36. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -66
@@ -1,152 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
-
5
- module Charming
6
- # DatabaseInstaller implements `charming db:install sqlite3`. It adds database support
7
- # to an existing Charming app by creating `config/database.rb`, `app/models/application_record.rb`,
8
- # `db/migrate/`, and `db/seeds.rb`, and patching the gemspec and root loader to include
9
- # the new dependencies and the `app/models` autoload directory.
10
- class DatabaseInstaller
11
- # *database* is the adapter name (only "sqlite3" is currently supported). *out* is the
12
- # status-output stream. *destination* is the app root.
13
- def initialize(database, out:, destination:)
14
- @database = database
15
- @out = out
16
- @destination = destination
17
- @app_name = Generators::Name.new(app_name_from_gemspec)
18
- end
19
-
20
- # Performs the install: writes the database config, application record, migrate directory,
21
- # seeds file, and patches the gemspec + root loader. Idempotent: existing files are
22
- # reported with "exist <path>" instead of being overwritten.
23
- def install
24
- raise Generators::Error, "Unsupported database: #{database.inspect}" unless database == "sqlite3"
25
-
26
- create_file("config/database.rb", database_config)
27
- create_file("app/models/application_record.rb", application_record)
28
- create_file("db/migrate/.keep", "")
29
- create_file("db/seeds.rb", %(# frozen_string_literal: true
30
- ))
31
- update_gemspec
32
- update_root_file
33
- end
34
-
35
- private
36
-
37
- # The database adapter, status stream, app destination, and derived app name.
38
- attr_reader :database, :out, :destination, :app_name
39
-
40
- # Writes *content* to *path* (relative to the app root), creating intermediate directories.
41
- # Reports "exist <path>" without overwriting when the file already exists.
42
- def create_file(path, content)
43
- absolute_path = File.join(destination, path)
44
- if File.exist?(absolute_path)
45
- out.puts "exist #{path}"
46
- return
47
- end
48
-
49
- FileUtils.mkdir_p(File.dirname(absolute_path))
50
- File.write(absolute_path, content)
51
- out.puts "create #{path}"
52
- end
53
-
54
- # Patches the gemspec to include the `db` directory in the gem files glob and to add
55
- # activerecord + sqlite3 dependencies.
56
- def update_gemspec
57
- update_file(gemspec_path) do |current|
58
- updated = current.sub('Dir.glob("{app,config,exe,lib}/**/*")', 'Dir.glob("{app,config,db,exe,lib}/**/*")')
59
- updated = insert_dependency(updated, "activerecord", "~> 8.1")
60
- insert_dependency(updated, "sqlite3", "~> 2.0")
61
- end
62
- end
63
-
64
- # Patches the root loader file (`lib/<app>.rb`) to require `config/database` and to push
65
- # the `app/models` autoload directory. Both edits are no-ops when already applied.
66
- def update_root_file
67
- update_file(root_file_path) do |current|
68
- updated = current
69
- updated = updated.sub(%(require "zeitwerk"\n), %(require "zeitwerk"\nrequire_relative "../config/database"\n)) unless updated.include?(%(require_relative "../config/database"))
70
- unless updated.include?(%[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})])
71
- updated = updated.sub(
72
- %[loader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n],
73
- %[loader.push_dir(File.expand_path("../app/models", __dir__), namespace: #{app_name.class_name})\nloader.push_dir(File.expand_path("../app/state", __dir__), namespace: #{app_name.class_name})\n]
74
- )
75
- end
76
- updated
77
- end
78
- end
79
-
80
- # Reads *path*, yields its contents to the block, and writes the result back when it
81
- # differs. Raises Generators::Error when the file is missing.
82
- def update_file(path)
83
- raise Generators::Error, "Missing file: #{relative_path(path)}" unless File.exist?(path)
84
-
85
- current = File.read(path)
86
- updated = yield current
87
- return if updated == current
88
-
89
- File.write(path, updated)
90
- out.puts "update #{relative_path(path)}"
91
- end
92
-
93
- # Inserts a `spec.add_dependency "name", "version"` line after the `charming` dependency
94
- # when it's not already present.
95
- def insert_dependency(content, gem_name, version)
96
- return content if content.include?(%(spec.add_dependency "#{gem_name}"))
97
-
98
- dependency = %( spec.add_dependency "#{gem_name}", "#{version}")
99
- content.sub(%( spec.add_dependency "charming"\n), %( spec.add_dependency "charming"\n#{dependency}\n))
100
- end
101
-
102
- # The contents of the new `config/database.rb` (establishes an SQLite connection to
103
- # `db/development.sqlite3`).
104
- def database_config
105
- %(# frozen_string_literal: true
106
-
107
- require "active_record"
108
- require "fileutils"
109
-
110
- database_path = File.expand_path("../db/development.sqlite3", __dir__)
111
- FileUtils.mkdir_p(File.dirname(database_path))
112
-
113
- ActiveRecord::Base.establish_connection(
114
- adapter: "sqlite3",
115
- database: database_path
116
- )
117
- )
118
- end
119
-
120
- # The contents of the new `app/models/application_record.rb` (abstract ActiveRecord base).
121
- def application_record
122
- %(# frozen_string_literal: true
123
-
124
- module #{app_name.class_name}
125
- class ApplicationRecord < ActiveRecord::Base
126
- self.abstract_class = true
127
- end
128
- end
129
- )
130
- end
131
-
132
- # Reads the app's gemspec filename to derive the app name.
133
- def app_name_from_gemspec
134
- File.basename(gemspec_path, ".gemspec")
135
- end
136
-
137
- # The path to the app's gemspec (raises when not found).
138
- def gemspec_path
139
- @gemspec_path ||= Dir.glob(File.join(destination, "*.gemspec")).first || raise(Generators::Error, "Run this command from a Charming app root")
140
- end
141
-
142
- # The path to the app's root loader file (`lib/<app_name>.rb`).
143
- def root_file_path
144
- File.join(destination, "lib", "#{app_name.snake_name}.rb")
145
- end
146
-
147
- # Strips the app destination prefix from *path* for human-friendly status output.
148
- def relative_path(path)
149
- path.delete_prefix("#{destination}/")
150
- end
151
- end
152
- end
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- # Focus manages a stack of focus scopes (rings) for a single controller class. Each scope has
5
- # a slot ring (a fixed list of named slots) and a current slot within that ring. Multiple
6
- # scopes can be stacked so the command palette, modals, and layouts can each have their own
7
- # focus contexts without interfering with one another.
8
- #
9
- # State lives under `session[:focus_state][controller_class_name]` so focus persists across
10
- # controller dispatches within the same session.
11
- class Focus
12
- # Returns the Focus object for *controller_class* under the given *session*, creating the
13
- # underlying session hash if absent.
14
- def self.for(session, controller_class)
15
- session[:focus_state] ||= {}
16
- key = controller_class.name
17
- session[:focus_state][key] ||= {scopes: []}
18
- new(session[:focus_state][key])
19
- end
20
-
21
- def initialize(state)
22
- @state = state
23
- end
24
-
25
- # Defines the primary focus ring for the controller with the given *slots*. Only effective
26
- # the first time it is called; subsequent calls are no-ops.
27
- def define(slots)
28
- return if @state[:scopes].any? { |scope| scope[:origin] == :ring }
29
-
30
- @state[:scopes] << build_scope(slots, :ring)
31
- end
32
-
33
- # Defines a layout scope (inserted after the primary ring and before modal scopes). *slots*
34
- # is the list of pane names; the previously-focused layout slot is preserved when it is still
35
- # part of the new ring.
36
- def define_layout(slots)
37
- current = current_layout_slot(slots)
38
- remove_scope(:layout)
39
- return if slots.empty?
40
-
41
- @state[:scopes].insert(layout_scope_index, build_scope(slots, :layout, current))
42
- end
43
-
44
- # Pushes a new focus scope with the given *slots* onto the stack. Used by modals, palettes,
45
- # and other overlays. *origin* is a label for the scope kind.
46
- def push_scope(slots, origin: :modal)
47
- @state[:scopes] << build_scope(slots, origin)
48
- end
49
-
50
- # Pops the topmost focus scope from the stack.
51
- def pop_scope
52
- @state[:scopes].pop
53
- end
54
-
55
- # Returns the currently focused slot, or nil when no scope is active.
56
- def current
57
- top && top[:current]
58
- end
59
-
60
- # Returns the slot ring of the topmost scope (an array of slot names). Empty when no scope.
61
- def ring
62
- top ? top[:ring] : []
63
- end
64
-
65
- # Sets the current slot within the topmost scope to *slot*. No-op when *slot* is not in the ring.
66
- def focus(slot)
67
- return unless ring.include?(slot)
68
-
69
- top[:current] = slot
70
- end
71
-
72
- # Cycles focus by *direction* (default +1 forward) within the topmost ring. No-op on an empty ring.
73
- def cycle(direction = +1)
74
- return if ring.empty?
75
-
76
- index = ring.index(current) || 0
77
- top[:current] = ring[(index + direction) % ring.length]
78
- end
79
-
80
- # True when *slot* is the current focus slot.
81
- def focused?(slot)
82
- current == slot
83
- end
84
-
85
- private
86
-
87
- # Returns the topmost scope hash (the last entry pushed onto `@state[:scopes]`).
88
- def top
89
- @state[:scopes].last
90
- end
91
-
92
- # Removes every scope whose origin equals *origin* (in place).
93
- def remove_scope(origin)
94
- @state[:scopes].reject! { |scope| scope[:origin] == origin }
95
- end
96
-
97
- # Returns the index in the scope stack where a layout scope belongs: just before the first
98
- # non-ring, non-layout scope (i.e., at the end of the "structural" stack).
99
- def layout_scope_index
100
- index = @state[:scopes].index { |scope| !%i[ring layout].include?(scope[:origin]) }
101
- index || @state[:scopes].length
102
- end
103
-
104
- # Returns the current layout scope's current slot, but only when it is still part of *slots*.
105
- # Otherwise returns the first slot in *slots* (so a new layout reverts to its first pane).
106
- def current_layout_slot(slots)
107
- current_slot = current_layout_scope&.fetch(:current)
108
- slots.include?(current_slot) ? current_slot : slots.first
109
- end
110
-
111
- # Returns the layout scope, or nil when no layout scope is present.
112
- def current_layout_scope
113
- @state[:scopes].find { |scope| scope[:origin] == :layout }
114
- end
115
-
116
- # Builds an immutable scope hash with the given *slots*, *origin*, and starting *current* slot.
117
- def build_scope(slots, origin, current = slots.first)
118
- {ring: slots.dup.freeze, current: current, origin: origin}
119
- end
120
- end
121
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- module Markdown
5
- # BlockRenderer dispatches Kramdown block-level elements (paragraph, header, list,
6
- # code block, etc.) to their individual rendering handlers. Handlers are built once
7
- # at construction time as a frozen hash of element-type symbols to callables.
8
- class BlockRenderer
9
- # *renderer* is the parent Renderer (used to wrap text, render inlines, and look up styles).
10
- def initialize(renderer:)
11
- @renderer = renderer
12
- build_handlers
13
- end
14
-
15
- # Renders *element* using the handler registered for `element.type`. Unknown types
16
- # fall through to `render_unknown`.
17
- def render(element, context:)
18
- handler = @handlers[element.type] || method(:render_unknown)
19
- handler.call(element, context)
20
- end
21
-
22
- private
23
-
24
- # The frozen hash of element-type → handler mapping.
25
- attr_reader :handlers
26
-
27
- # Builds the handler hash. Each handler is a small lambda that calls back into the
28
- # parent renderer (or one of the private render_* methods below).
29
- def build_handlers
30
- r = @renderer
31
- @handlers = {
32
- p: ->(element, context) { r.wrap(r.render_inlines(element.children), width: context.width) },
33
- header: ->(element, context) { send(:render_header, element, context) },
34
- blockquote: ->(element, context) { send(:render_blockquote, element, context) },
35
- ul: ->(element, context) { send(:render_list, element, ordered: false, context: context) },
36
- ol: ->(element, context) { send(:render_list, element, ordered: true, context: context) },
37
- li: ->(element, context) { r.render_blocks(element.children, list_depth: context.list_depth, width: context.width) },
38
- codeblock: ->(element, _context) { send(:render_codeblock, element) },
39
- hr: ->(element, context) { send(:render_rule, width: context.width) },
40
- blank: ->(_element, _context) {}
41
- }.freeze
42
- end
43
-
44
- # Fallback for unknown block types: wraps the raw value when there are no children,
45
- # otherwise recurses into the children.
46
- def render_unknown(element, context)
47
- return @renderer.wrap(element.value.to_s, width: context.width) if element.children.empty?
48
-
49
- @renderer.render_blocks(element.children, list_depth: context.list_depth, width: context.width)
50
- end
51
-
52
- # Renders a header element, using the `markdown_heading` style for h1 and the
53
- # `markdown_subheading` style for h2+.
54
- def render_header(element, context)
55
- rendered = @renderer.wrap(@renderer.render_inlines(element.children), width: context.width)
56
- style = if element.options[:level].to_i == 1
57
- @renderer.style_for(:markdown_heading, fallback: @renderer.theme_style(:title))
58
- else
59
- @renderer.style_for(:markdown_subheading, fallback: @renderer.theme_style(:title))
60
- end
61
- style.render(rendered)
62
- end
63
-
64
- def render_blockquote(element, context)
65
- quote_width = context.width ? [context.width - 2, 1].max : nil
66
- rendered = @renderer.render_blocks(element.children, list_depth: context.list_depth, width: quote_width)
67
- border = @renderer.style_for(:markdown_quote_border, fallback: @renderer.theme_style(:border)).render("|")
68
- quote_style = @renderer.style_for(:markdown_quote, fallback: @renderer.theme_style(:muted))
69
-
70
- rendered.lines(chomp: true).map { |line| "#{border} #{quote_style.render(line)}" }.join("\n")
71
- end
72
-
73
- def render_list(element, ordered:, context:)
74
- element.children.each_with_index.map do |item, index|
75
- marker = ordered ? "#{ordered_start(element) + index}." : "-"
76
- render_list_item(item, marker: marker, context: context)
77
- end.join("\n")
78
- end
79
-
80
- def render_list_item(element, marker:, context:)
81
- indent = " " * context.list_depth
82
- first_prefix = "#{indent}#{marker} "
83
- rest_prefix = "#{indent}#{" " * (marker.length + 1)}"
84
- item_width = context.width ? [context.width - UI::Width.measure(first_prefix), 1].max : nil
85
- body = @renderer.render_blocks(element.children, list_depth: context.list_depth + 1, width: item_width)
86
-
87
- body.lines(chomp: true).each_with_index.map do |line, index|
88
- "#{index.zero? ? first_prefix : rest_prefix}#{line}"
89
- end.join("\n")
90
- end
91
-
92
- def ordered_start(element)
93
- element.options.fetch(:start, 1).to_i
94
- end
95
-
96
- def render_codeblock(element)
97
- code = element.value.to_s
98
- rendered = if @renderer.syntax_highlighting
99
- SyntaxHighlighter.new(theme: @renderer.theme).render(code, language: code_language(element))
100
- else
101
- @renderer.style_for(:markdown_code, fallback: @renderer.theme_style(:warn)).render(code)
102
- end
103
-
104
- rendered.lines(chomp: true).map { |line| " #{line}" }.join("\n")
105
- end
106
-
107
- def render_rule(width:)
108
- @renderer.style_for(:markdown_rule, fallback: @renderer.theme_style(:border)).render("-" * (width || Renderer::DEFAULT_RULE_WIDTH))
109
- end
110
-
111
- def code_language(element)
112
- return element.options[:lang] if element.options[:lang]
113
-
114
- element.attr["class"].to_s[/language-([^\s]+)/, 1]
115
- end
116
- end
117
- end
118
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Charming
4
- module Markdown
5
- # InlineRenderer dispatches Kramdown inline-level elements (text, strong, em,
6
- # codespan, link, line break, HTML entity) to their individual rendering handlers.
7
- # Handlers are built once at construction as a frozen hash of element-type symbols
8
- # to callables.
9
- class InlineRenderer
10
- # *renderer* is the parent Renderer (used to render nested inlines and look up styles).
11
- def initialize(renderer:)
12
- @renderer = renderer
13
- build_handlers
14
- end
15
-
16
- # Renders *element* using the handler registered for `element.type`. Unknown types
17
- # fall through to `render_unknown`.
18
- def render(element, context:)
19
- handler = @handlers[element.type] || method(:render_unknown)
20
- handler.call(element, context)
21
- end
22
-
23
- private
24
-
25
- # The frozen hash of element-type → handler mapping.
26
- attr_reader :handlers
27
-
28
- # Builds the handler hash for text, strong, em, codespan, link, br, and entity.
29
- def build_handlers
30
- r = @renderer
31
- @handlers = {
32
- text: ->(element, _context) { element.value.to_s },
33
- strong: ->(element, context) { render_styled(element, context, :markdown_strong) { |s| s.bold } },
34
- em: ->(element, context) { render_styled(element, context, :markdown_emphasis) { |s| s.italic } },
35
- codespan: ->(element, _context) { r.style_for(:markdown_inline_code, fallback: r.theme_style(:warn)).render(element.value.to_s) },
36
- a: ->(element, context) { send(:render_link, element, context) },
37
- br: ->(_element, _context) { "\n" },
38
- entity: ->(element, _context) { element.value.respond_to?(:char) ? element.value.char : element.value.to_s }
39
- }.freeze
40
- end
41
-
42
- # Renders a styled inline (strong/em) by first rendering children, then applying
43
- # the theme style and the block-form (e.g., `bold`/`italic`) decoration.
44
- def render_styled(element, context, style_name)
45
- rendered = @renderer.render_inlines(element.children, width: context.width)
46
- style = @renderer.style_for(style_name, fallback: yield(@renderer.theme_style(:text)))
47
- style.render(rendered)
48
- end
49
-
50
- # Renders a Markdown link as "label <href>" (URL omitted when empty), styled with
51
- # the markdown_link theme token or the info+underline fallback.
52
- def render_link(element, context)
53
- label = @renderer.render_inlines(element.children, width: context.width)
54
- href = element.attr["href"].to_s
55
- rendered = href.empty? ? label : "#{label} <#{href}>"
56
- @renderer.style_for(:markdown_link, fallback: @renderer.theme_style(:info).underline).render(rendered)
57
- end
58
-
59
- # Fallback for unknown inline types: returns the value when there are no children,
60
- # otherwise recurses into the children.
61
- def render_unknown(element, context)
62
- element.children.empty? ? element.value.to_s : @renderer.render_inlines(element.children, width: context.width)
63
- end
64
- end
65
- end
66
- end