charming 0.1.2 → 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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/lib/charming/application.rb +22 -5
  3. data/lib/charming/cli.rb +3 -3
  4. data/lib/charming/controller/class_methods.rb +2 -2
  5. data/lib/charming/controller/command_palette.rb +2 -2
  6. data/lib/charming/controller/component_dispatching.rb +47 -3
  7. data/lib/charming/controller/focus.rb +123 -0
  8. data/lib/charming/controller/focus_management.rb +1 -1
  9. data/lib/charming/controller/rendering.rb +6 -17
  10. data/lib/charming/controller/session_state.rb +12 -1
  11. data/lib/charming/controller.rb +11 -2
  12. data/lib/charming/database/commands.rb +106 -0
  13. data/lib/charming/generators/component_generator.rb +1 -1
  14. data/lib/charming/generators/database_installer.rb +154 -0
  15. data/lib/charming/generators/model_generator.rb +2 -10
  16. data/lib/charming/generators/name.rb +1 -1
  17. data/lib/charming/generators/templates/app/application.template +1 -1
  18. data/lib/charming/generators/templates/app/layout.template +3 -6
  19. data/lib/charming/generators/templates/app/view.template +1 -1
  20. data/lib/charming/generators/templates/component/component.rb.template +1 -1
  21. data/lib/charming/generators/templates/screen/view.rb.template +1 -1
  22. data/lib/charming/generators/templates/view/view.rb.template +1 -1
  23. data/lib/charming/generators/view_generator.rb +1 -1
  24. data/lib/charming/internal/renderer/differential.rb +13 -5
  25. data/lib/charming/internal/terminal/tty_backend.rb +22 -2
  26. data/lib/charming/presentation/component.rb +3 -5
  27. data/lib/charming/presentation/components/activity_indicator.rb +173 -134
  28. data/lib/charming/presentation/components/command_palette.rb +94 -96
  29. data/lib/charming/presentation/components/command_palette_modal.rb +33 -0
  30. data/lib/charming/presentation/components/empty_state.rb +47 -49
  31. data/lib/charming/presentation/components/form/builder.rb +52 -54
  32. data/lib/charming/presentation/components/form/confirm.rb +49 -51
  33. data/lib/charming/presentation/components/form/field.rb +94 -96
  34. data/lib/charming/presentation/components/form/input.rb +53 -55
  35. data/lib/charming/presentation/components/form/note.rb +27 -29
  36. data/lib/charming/presentation/components/form/select.rb +84 -86
  37. data/lib/charming/presentation/components/form/textarea.rb +67 -69
  38. data/lib/charming/presentation/components/form.rb +120 -122
  39. data/lib/charming/presentation/components/keyboard_handler.rb +41 -43
  40. data/lib/charming/presentation/components/list.rb +123 -125
  41. data/lib/charming/presentation/components/markdown.rb +21 -23
  42. data/lib/charming/presentation/components/modal.rb +46 -48
  43. data/lib/charming/presentation/components/progressbar.rb +51 -53
  44. data/lib/charming/presentation/components/spinner.rb +40 -42
  45. data/lib/charming/presentation/components/table.rb +109 -111
  46. data/lib/charming/presentation/components/text_area.rb +219 -221
  47. data/lib/charming/presentation/components/text_input.rb +120 -122
  48. data/lib/charming/presentation/components/viewport.rb +218 -220
  49. data/lib/charming/presentation/layout/builder.rb +64 -66
  50. data/lib/charming/presentation/layout/overlay.rb +48 -50
  51. data/lib/charming/presentation/layout/pane.rb +129 -118
  52. data/lib/charming/presentation/layout/rect.rb +19 -16
  53. data/lib/charming/presentation/layout/screen_layout.rb +47 -42
  54. data/lib/charming/presentation/layout/split.rb +107 -102
  55. data/lib/charming/presentation/layout.rb +28 -30
  56. data/lib/charming/presentation/markdown/render_context.rb +30 -14
  57. data/lib/charming/presentation/markdown/renderer.rb +302 -79
  58. data/lib/charming/presentation/markdown/style_config.rb +215 -0
  59. data/lib/charming/presentation/markdown/syntax_highlighter.rb +58 -59
  60. data/lib/charming/presentation/markdown.rb +4 -6
  61. data/lib/charming/presentation/template_view.rb +22 -24
  62. data/lib/charming/presentation/templates/erb_handler.rb +4 -6
  63. data/lib/charming/presentation/templates.rb +47 -49
  64. data/lib/charming/presentation/ui/ansi_codes.rb +66 -68
  65. data/lib/charming/presentation/ui/ansi_slicer.rb +67 -69
  66. data/lib/charming/presentation/ui/border.rb +24 -26
  67. data/lib/charming/presentation/ui/border_painter.rb +37 -39
  68. data/lib/charming/presentation/ui/canvas.rb +59 -61
  69. data/lib/charming/presentation/ui/style.rb +173 -175
  70. data/lib/charming/presentation/ui/theme.rb +133 -135
  71. data/lib/charming/presentation/ui/width.rb +12 -14
  72. data/lib/charming/presentation/ui.rb +69 -71
  73. data/lib/charming/presentation/view.rb +110 -105
  74. data/lib/charming/router.rb +3 -8
  75. data/lib/charming/runtime.rb +25 -10
  76. data/lib/charming/version.rb +1 -1
  77. data/lib/charming.rb +5 -4
  78. metadata +43 -9
  79. data/lib/charming/database_commands.rb +0 -103
  80. data/lib/charming/database_installer.rb +0 -152
  81. data/lib/charming/focus.rb +0 -121
  82. data/lib/charming/presentation/markdown/block_renderers.rb +0 -120
  83. data/lib/charming/presentation/markdown/inline_renderers.rb +0 -68
@@ -1,94 +1,92 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module UI
6
- # ANSISlicer extracts a visible substring from a string that may contain ANSI
7
- # escape sequences, preserving the styling that is active at the start of
8
- # the slice and emitting a trailing reset if any styled content was copied.
9
- class ANSISlicer
10
- def self.slice(line, start_column, width)
11
- return "" unless width.positive?
12
-
13
- slice_range(line.to_s, start_column, start_column + width)
14
- end
4
+ module UI
5
+ # ANSISlicer extracts a visible substring from a string that may contain ANSI
6
+ # escape sequences, preserving the styling that is active at the start of
7
+ # the slice and emitting a trailing reset if any styled content was copied.
8
+ class ANSISlicer
9
+ def self.slice(line, start_column, width)
10
+ return "" unless width.positive?
11
+
12
+ slice_range(line.to_s, start_column, start_column + width)
13
+ end
15
14
 
16
- def self.slice_range(line, start_column, end_column)
17
- state = {column: 0, output: +"", active: [], started: false, styled: false}
15
+ def self.slice_range(line, start_column, end_column)
16
+ state = {column: 0, output: +"", active: [], started: false, styled: false}
18
17
 
19
- each_ansi_or_char(line) do |token, ansi|
20
- if ansi
21
- slice_ansi_token(token, state, start_column, end_column)
22
- else
23
- slice_char(token, state, start_column, end_column)
24
- end
18
+ each_ansi_or_char(line) do |token, ansi|
19
+ if ansi
20
+ slice_ansi_token(token, state, start_column, end_column)
21
+ else
22
+ slice_char(token, state, start_column, end_column)
25
23
  end
26
-
27
- terminate_slice(state)
28
24
  end
29
25
 
30
- def self.each_ansi_or_char(line)
31
- index = 0
32
- while index < line.length
33
- match = line.match(Width::ANSI_PATTERN, index)
34
- if match&.begin(0) == index
35
- yield match[0], true
36
- index = match.end(0)
37
- else
38
- yield line[index], false
39
- index += 1
40
- end
26
+ terminate_slice(state)
27
+ end
28
+
29
+ def self.each_ansi_or_char(line)
30
+ index = 0
31
+ while index < line.length
32
+ match = line.match(Width::ANSI_PATTERN, index)
33
+ if match&.begin(0) == index
34
+ yield match[0], true
35
+ index = match.end(0)
36
+ else
37
+ yield line[index], false
38
+ index += 1
41
39
  end
42
40
  end
41
+ end
43
42
 
44
- def self.slice_ansi_token(token, state, start_column, end_column)
45
- started = state[:started]
46
- update_active_styles(state[:active], token)
47
- return unless state[:column].between?(start_column, end_column - 1)
43
+ def self.slice_ansi_token(token, state, start_column, end_column)
44
+ started = state[:started]
45
+ update_active_styles(state[:active], token)
46
+ return unless state[:column].between?(start_column, end_column - 1)
48
47
 
49
- start_slice(state)
50
- if started
51
- state[:output] << token
52
- state[:styled] = !token.include?("[0m")
53
- end
48
+ start_slice(state)
49
+ if started
50
+ state[:output] << token
51
+ state[:styled] = !token.include?("[0m")
54
52
  end
53
+ end
55
54
 
56
- def self.slice_char(char, state, start_column, end_column)
57
- char_width = Width.measure(char)
58
- char_start = state[:column]
59
- char_end = char_start + char_width
60
- state[:column] = char_end
61
- return unless char_end > start_column && char_start < end_column
55
+ def self.slice_char(char, state, start_column, end_column)
56
+ char_width = Width.measure(char)
57
+ char_start = state[:column]
58
+ char_end = char_start + char_width
59
+ state[:column] = char_end
60
+ return unless char_end > start_column && char_start < end_column
62
61
 
63
- start_slice(state)
64
- state[:output] << char
65
- end
62
+ start_slice(state)
63
+ state[:output] << char
64
+ end
66
65
 
67
- def self.start_slice(state)
68
- return if state[:started]
66
+ def self.start_slice(state)
67
+ return if state[:started]
69
68
 
70
- state[:output] << state[:active].join
71
- state[:styled] = true unless state[:active].empty?
72
- state[:started] = true
73
- end
69
+ state[:output] << state[:active].join
70
+ state[:styled] = true unless state[:active].empty?
71
+ state[:started] = true
72
+ end
74
73
 
75
- def self.terminate_slice(state)
76
- return state[:output] if !state[:styled] || state[:output].empty?
74
+ def self.terminate_slice(state)
75
+ return state[:output] if !state[:styled] || state[:output].empty?
77
76
 
78
- "#{state[:output]}\e[0m"
79
- end
77
+ "#{state[:output]}\e[0m"
78
+ end
80
79
 
81
- def self.update_active_styles(active, token)
82
- if token.include?("[0m")
83
- active.clear
84
- else
85
- active << token
86
- end
80
+ def self.update_active_styles(active, token)
81
+ if token.include?("[0m")
82
+ active.clear
83
+ else
84
+ active << token
87
85
  end
88
-
89
- private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
90
- :start_slice, :terminate_slice, :update_active_styles
91
86
  end
87
+
88
+ private_class_method :each_ansi_or_char, :slice_ansi_token, :slice_char,
89
+ :start_slice, :terminate_slice, :update_active_styles
92
90
  end
93
91
  end
94
92
  end
@@ -1,35 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module UI
6
- class Border
7
- attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
4
+ module UI
5
+ class Border
6
+ attr_reader :top_left, :top_right, :bottom_left, :bottom_right, :horizontal, :vertical
8
7
 
9
- def initialize(corners:, edges:)
10
- @top_left, @top_right, @bottom_left, @bottom_right = corners
11
- @horizontal, @vertical = edges
12
- end
13
-
14
- def self.fetch(name)
15
- STYLES.fetch(name.to_sym)
16
- end
8
+ def initialize(corners:, edges:)
9
+ @top_left, @top_right, @bottom_left, @bottom_right = corners
10
+ @horizontal, @vertical = edges
17
11
  end
18
12
 
19
- Border::STYLES = {
20
- normal: Border.new(
21
- corners: ["+", "+", "+", "+"], edges: ["-", "|"]
22
- ),
23
- rounded: Border.new(
24
- corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
25
- ),
26
- thick: Border.new(
27
- corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
28
- ),
29
- double: Border.new(
30
- corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
31
- )
32
- }.freeze
13
+ def self.fetch(name)
14
+ STYLES.fetch(name.to_sym)
15
+ end
33
16
  end
17
+
18
+ Border::STYLES = {
19
+ normal: Border.new(
20
+ corners: ["+", "+", "+", "+"], edges: ["-", "|"]
21
+ ),
22
+ rounded: Border.new(
23
+ corners: ["╭", "╮", "╰", "╯"], edges: ["─", "│"]
24
+ ),
25
+ thick: Border.new(
26
+ corners: ["┏", "┓", "┗", "┛"], edges: ["━", "┃"]
27
+ ),
28
+ double: Border.new(
29
+ corners: ["╔", "╗", "╚", "╝"], edges: ["═", "║"]
30
+ )
31
+ }.freeze
34
32
  end
35
33
  end
@@ -1,57 +1,55 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module UI
6
- class BorderPainter
7
- DEFAULT_SIDES = %i[top right bottom left].freeze
8
-
9
- def initialize(border:, sides: nil, foreground: nil, background: nil)
10
- @border = border
11
- @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
12
- @foreground = foreground
13
- @background = background
14
- end
4
+ module UI
5
+ class BorderPainter
6
+ DEFAULT_SIDES = %i[top right bottom left].freeze
7
+
8
+ def initialize(border:, sides: nil, foreground: nil, background: nil)
9
+ @border = border
10
+ @sides = Array(sides || DEFAULT_SIDES).map(&:to_sym)
11
+ @foreground = foreground
12
+ @background = background
13
+ end
15
14
 
16
- def paint(lines, inner_width)
17
- horizontal = @border.horizontal * inner_width
18
- body = lines.map { |line| border_line(line, inner_width) }
15
+ def paint(lines, inner_width)
16
+ horizontal = @border.horizontal * inner_width
17
+ body = lines.map { |line| border_line(line, inner_width) }
19
18
 
20
- [top_border(horizontal), *body, bottom_border(horizontal)].compact
21
- end
19
+ [top_border(horizontal), *body, bottom_border(horizontal)].compact
20
+ end
22
21
 
23
- private
22
+ private
24
23
 
25
- def border_line(line, width)
26
- left = @sides.include?(:left) ? render_border(@border.vertical) : ""
27
- right = @sides.include?(:right) ? render_border(@border.vertical) : ""
24
+ def border_line(line, width)
25
+ left = @sides.include?(:left) ? render_border(@border.vertical) : ""
26
+ right = @sides.include?(:right) ? render_border(@border.vertical) : ""
28
27
 
29
- "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
30
- end
28
+ "#{left}#{line}#{" " * (width - Width.measure(line))}#{right}"
29
+ end
31
30
 
32
- def top_border(horizontal)
33
- return unless @sides.include?(:top)
34
- return render_border(horizontal) unless full_horizontal?
31
+ def top_border(horizontal)
32
+ return unless @sides.include?(:top)
33
+ return render_border(horizontal) unless full_horizontal?
35
34
 
36
- render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
37
- end
35
+ render_border("#{@border.top_left}#{horizontal}#{@border.top_right}")
36
+ end
38
37
 
39
- def bottom_border(horizontal)
40
- return unless @sides.include?(:bottom)
41
- return render_border(horizontal) unless full_horizontal?
38
+ def bottom_border(horizontal)
39
+ return unless @sides.include?(:bottom)
40
+ return render_border(horizontal) unless full_horizontal?
42
41
 
43
- render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
44
- end
42
+ render_border("#{@border.bottom_left}#{horizontal}#{@border.bottom_right}")
43
+ end
45
44
 
46
- def full_horizontal?
47
- @sides.include?(:left) && @sides.include?(:right)
48
- end
45
+ def full_horizontal?
46
+ @sides.include?(:left) && @sides.include?(:right)
47
+ end
49
48
 
50
- def render_border(value)
51
- return value unless @foreground
49
+ def render_border(value)
50
+ return value unless @foreground
52
51
 
53
- Style.new(foreground: @foreground, background: @background).render(value)
54
- end
52
+ Style.new(foreground: @foreground, background: @background).render(value)
55
53
  end
56
54
  end
57
55
  end
@@ -1,81 +1,79 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Charming
4
- module Presentation
5
- module UI
6
- # Canvas is a 2D character grid of fixed width and height that supports
7
- # placing content at (row, column) coordinates and overlaying one block
8
- # on top of another. Construct via .new(width, height) for a blank grid
9
- # or .parse(string) to reconstruct from rendered output.
10
- class Canvas
11
- def initialize(width, height)
12
- @width = width
13
- @height = height
14
- @grid = Array.new(height) { " " * width }
15
- end
4
+ module UI
5
+ # Canvas is a 2D character grid of fixed width and height that supports
6
+ # placing content at (row, column) coordinates and overlaying one block
7
+ # on top of another. Construct via .new(width, height) for a blank grid
8
+ # or .parse(string) to reconstruct from rendered output.
9
+ class Canvas
10
+ def initialize(width, height)
11
+ @width = width
12
+ @height = height
13
+ @grid = Array.new(height) { " " * width }
14
+ end
16
15
 
17
- def self.parse(string)
18
- lines = string.to_s.lines(chomp: true)
19
- width = UI.block_width(lines)
20
- canvas = new(width, lines.length)
21
- lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
22
- canvas
23
- end
16
+ def self.parse(string)
17
+ lines = string.to_s.lines(chomp: true)
18
+ width = UI.block_width(lines)
19
+ canvas = new(width, lines.length)
20
+ lines.each_with_index { |line, i| canvas.instance_variable_get(:@grid)[i] = line }
21
+ canvas
22
+ end
24
23
 
25
- def to_s
26
- @grid.join("\n")
27
- end
24
+ def to_s
25
+ @grid.join("\n")
26
+ end
28
27
 
29
- def place(block, top: 0, left: 0, background: nil)
30
- lines = block.to_s.lines(chomp: true)
31
- row = Canvas.offset(top, @height, lines.length)
32
- column = Canvas.offset(left, @width, UI.block_width(lines))
33
- draw_lines(lines, row: row, column: column, onto: @grid)
34
- rendered = to_s
35
- background ? UI::Style.new.background(background).render(rendered) : rendered
36
- end
28
+ def place(block, top: 0, left: 0, background: nil)
29
+ lines = block.to_s.lines(chomp: true)
30
+ row = Canvas.offset(top, @height, lines.length)
31
+ column = Canvas.offset(left, @width, UI.block_width(lines))
32
+ draw_lines(lines, row: row, column: column, onto: @grid)
33
+ rendered = to_s
34
+ background ? UI::Style.new.background(background).render(rendered) : rendered
35
+ end
37
36
 
38
- def overlay(other, top: :center, left: :center)
39
- overlay_lines = other.to_s.lines(chomp: true)
40
- row = Canvas.offset(top, @grid.length, overlay_lines.length)
41
- column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
42
- draw_lines(overlay_lines, row: row, column: column, onto: @grid)
43
- self
44
- end
37
+ def overlay(other, top: :center, left: :center)
38
+ overlay_lines = other.to_s.lines(chomp: true)
39
+ row = Canvas.offset(top, @grid.length, overlay_lines.length)
40
+ column = Canvas.offset(left, @width, UI.block_width(overlay_lines))
41
+ draw_lines(overlay_lines, row: row, column: column, onto: @grid)
42
+ self
43
+ end
45
44
 
46
- def self.offset(value, available, size)
47
- return [(available - size) / 2, 0].max if value == :center
45
+ def self.offset(value, available, size)
46
+ return [(available - size) / 2, 0].max if value == :center
48
47
 
49
- value
50
- end
48
+ value
49
+ end
51
50
 
52
- private
51
+ private
53
52
 
54
- def draw_lines(lines, row:, column:, onto:)
55
- lines.each_with_index do |line, index|
56
- line_index = row + index
57
- next if line_index.negative? || line_index >= onto.length
53
+ def draw_lines(lines, row:, column:, onto:)
54
+ lines.each_with_index do |line, index|
55
+ line_index = row + index
56
+ next if line_index.negative? || line_index >= onto.length
58
57
 
59
- onto[line_index] = compose_line(onto[line_index], line, column)
60
- end
58
+ onto[line_index] = compose_line(onto[line_index], line, column)
61
59
  end
60
+ end
62
61
 
63
- def compose_line(base_line, overlay_line, column)
64
- return ANSISlicer.slice(base_line, 0, @width) if column >= @width
65
- return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
62
+ def compose_line(base_line, overlay_line, column)
63
+ return ANSISlicer.slice(base_line, 0, @width) if column >= @width
64
+ return ANSISlicer.slice(base_line, 0, @width) if column + Width.measure(overlay_line) <= 0
66
65
 
67
- target_column = [column, 0].max
68
- overlay_start = [0 - column, 0].max
69
- overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
70
- overlay_width = Width.measure(overlay)
71
- return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
66
+ target_column = [column, 0].max
67
+ overlay_start = [0 - column, 0].max
68
+ overlay = ANSISlicer.slice(overlay_line, overlay_start, @width - target_column)
69
+ overlay_width = Width.measure(overlay)
70
+ return ANSISlicer.slice(base_line, 0, @width) if overlay_width.zero?
72
71
 
73
- right_column = target_column + overlay_width
72
+ right_column = target_column + overlay_width
74
73
 
75
- ANSISlicer.slice(base_line, 0, target_column) +
76
- overlay +
77
- ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
78
- end
74
+ ANSISlicer.slice(base_line, 0, target_column) +
75
+ overlay +
76
+ ANSISlicer.slice(base_line, right_column, [@width - right_column, 0].max)
79
77
  end
80
78
  end
81
79
  end