charming 0.1.0 → 0.1.1
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 +38 -378
- data/lib/charming/application.rb +3 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +39 -3
- data/lib/charming/controller.rb +146 -24
- data/lib/charming/database_commands.rb +87 -0
- data/lib/charming/database_installer.rb +125 -0
- data/lib/charming/events/key_event.rb +15 -0
- data/lib/charming/events/mouse_event.rb +42 -0
- data/lib/charming/events/resize_event.rb +9 -0
- data/lib/charming/events/task_event.rb +19 -0
- data/lib/charming/events/timer_event.rb +9 -0
- data/lib/charming/generators/app_generator/app_spec_templates.rb +12 -8
- data/lib/charming/generators/app_generator/basic_templates.rb +14 -2
- data/lib/charming/generators/app_generator/component_templates.rb +1 -1
- data/lib/charming/generators/app_generator/controller_template.rb +3 -12
- data/lib/charming/generators/app_generator/database_templates.rb +45 -0
- data/lib/charming/generators/app_generator/layout_template.rb +51 -145
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +7 -8
- data/lib/charming/generators/app_generator/{model_templates.rb → state_templates.rb} +5 -5
- data/lib/charming/generators/app_generator/view_template.rb +12 -18
- data/lib/charming/generators/app_generator.rb +37 -11
- data/lib/charming/generators/component_generator.rb +1 -1
- data/lib/charming/generators/controller_generator.rb +1 -4
- data/lib/charming/generators/model_generator.rb +119 -0
- data/lib/charming/generators/name.rb +0 -4
- data/lib/charming/generators/screen_generator.rb +14 -28
- data/lib/charming/generators/view_generator.rb +11 -14
- data/lib/charming/internal/renderer/differential.rb +2 -3
- data/lib/charming/internal/terminal/tty_backend.rb +25 -8
- data/lib/charming/presentation/component.rb +10 -0
- data/lib/charming/presentation/components/activity_indicator.rb +160 -0
- data/lib/charming/presentation/components/command_palette.rb +120 -0
- data/lib/charming/presentation/components/empty_state.rb +43 -0
- data/lib/charming/presentation/components/form/builder.rb +48 -0
- data/lib/charming/presentation/components/form/confirm.rb +56 -0
- data/lib/charming/presentation/components/form/field.rb +96 -0
- data/lib/charming/presentation/components/form/input.rb +57 -0
- data/lib/charming/presentation/components/form/note.rb +32 -0
- data/lib/charming/presentation/components/form/select.rb +89 -0
- data/lib/charming/presentation/components/form/textarea.rb +70 -0
- data/lib/charming/presentation/components/form.rb +127 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +104 -0
- data/lib/charming/presentation/components/markdown.rb +25 -0
- data/lib/charming/presentation/components/modal.rb +50 -0
- data/lib/charming/presentation/components/progressbar.rb +57 -0
- data/lib/charming/presentation/components/spinner.rb +39 -0
- data/lib/charming/presentation/components/table.rb +118 -0
- data/lib/charming/presentation/components/text_area.rb +219 -0
- data/lib/charming/presentation/components/text_input.rb +105 -0
- data/lib/charming/presentation/components/viewport.rb +220 -0
- data/lib/charming/presentation/layout.rb +43 -0
- data/lib/charming/presentation/markdown/renderer.rb +203 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +63 -0
- data/lib/charming/presentation/markdown.rb +8 -0
- data/lib/charming/presentation/template_view.rb +27 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +51 -0
- data/lib/charming/presentation/ui/border.rb +35 -0
- data/lib/charming/presentation/ui/style.rb +246 -0
- data/lib/charming/presentation/ui/theme.rb +180 -0
- data/lib/charming/{ui → presentation/ui}/themes/phosphor.json +2 -2
- data/lib/charming/presentation/ui/width.rb +26 -0
- data/lib/charming/presentation/ui.rb +232 -0
- data/lib/charming/presentation/view.rb +118 -0
- data/lib/charming/runtime.rb +7 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +28 -0
- data/lib/charming/tasks/task.rb +9 -0
- data/lib/charming/{task_executor.rb → tasks/threaded_executor.rb} +4 -27
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +4 -0
- metadata +114 -29
- data/lib/charming/component.rb +0 -8
- data/lib/charming/components/activity_indicator.rb +0 -158
- data/lib/charming/components/command_palette.rb +0 -118
- data/lib/charming/components/keyboard_handler.rb +0 -22
- data/lib/charming/components/list.rb +0 -105
- data/lib/charming/components/modal.rb +0 -48
- data/lib/charming/components/progressbar.rb +0 -55
- data/lib/charming/components/spinner.rb +0 -37
- data/lib/charming/components/table.rb +0 -115
- data/lib/charming/components/text_input.rb +0 -103
- data/lib/charming/components/viewport.rb +0 -191
- data/lib/charming/key_event.rb +0 -13
- data/lib/charming/mouse_event.rb +0 -40
- data/lib/charming/resize_event.rb +0 -7
- data/lib/charming/task.rb +0 -7
- data/lib/charming/task_event.rb +0 -17
- data/lib/charming/timer_event.rb +0 -7
- data/lib/charming/ui/border.rb +0 -33
- data/lib/charming/ui/style.rb +0 -244
- data/lib/charming/ui/theme.rb +0 -178
- data/lib/charming/ui/width.rb +0 -24
- data/lib/charming/ui.rb +0 -230
- data/lib/charming/view.rb +0 -116
- /data/lib/charming/{generators.rb → generators/error.rb} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Progressbar < Component
|
|
7
|
+
attr_accessor :total, :current, :label, :complete, :incomplete, :bar_format
|
|
8
|
+
|
|
9
|
+
def initialize(total:, complete: "=", incomplete: " ", bar_format: :classic, label: nil)
|
|
10
|
+
super()
|
|
11
|
+
@total = [total.to_i, 0].max
|
|
12
|
+
@complete = complete.to_s
|
|
13
|
+
@incomplete = incomplete.to_s
|
|
14
|
+
@bar_format = bar_format.to_sym
|
|
15
|
+
@label = label
|
|
16
|
+
@current = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def tick(count = 1)
|
|
20
|
+
update(@current + count)
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def update(value)
|
|
25
|
+
@current = value.to_i.clamp(0, @total)
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def complete!
|
|
30
|
+
@current = @total
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def render
|
|
35
|
+
width = [@total, 1].max
|
|
36
|
+
completed = completed_width(width)
|
|
37
|
+
incomplete = width - completed
|
|
38
|
+
incomplete -= 1 if @current.zero?
|
|
39
|
+
bar = (@complete * completed) + (@incomplete * incomplete)
|
|
40
|
+
result = "[" + bar + "]"
|
|
41
|
+
|
|
42
|
+
return result unless @label
|
|
43
|
+
|
|
44
|
+
"#{result} #{@label}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def completed_width(width)
|
|
50
|
+
return 0 unless @total.positive?
|
|
51
|
+
|
|
52
|
+
((width * @current) / @total.to_f).round
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class Spinner < Component
|
|
7
|
+
DEFAULT_FRAMES = ["-", "\\", "|", "/"].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :frames, :index, :label
|
|
10
|
+
|
|
11
|
+
def initialize(frames: DEFAULT_FRAMES, index: 0, label: nil)
|
|
12
|
+
super()
|
|
13
|
+
raise ArgumentError, "frames cannot be empty" if frames.empty?
|
|
14
|
+
|
|
15
|
+
@frames = frames
|
|
16
|
+
@index = index
|
|
17
|
+
@label = label
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def tick
|
|
21
|
+
@index = (index + 1) % frames.length
|
|
22
|
+
self
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render
|
|
26
|
+
return frame unless label
|
|
27
|
+
|
|
28
|
+
"#{frame} #{label}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def frame
|
|
34
|
+
frames.fetch(index % frames.length)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-table"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Presentation
|
|
7
|
+
module Components
|
|
8
|
+
class Table < Component
|
|
9
|
+
include KeyboardHandler
|
|
10
|
+
|
|
11
|
+
KEY_ACTIONS = {
|
|
12
|
+
up: :move_up,
|
|
13
|
+
down: :move_down,
|
|
14
|
+
home: :move_home,
|
|
15
|
+
end: :move_end
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
HEADER_HEIGHT = 2
|
|
19
|
+
|
|
20
|
+
attr_reader :header, :rows, :selected_index
|
|
21
|
+
|
|
22
|
+
def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
|
|
23
|
+
super()
|
|
24
|
+
@header = Array(header).map(&:to_s)
|
|
25
|
+
@rows = Array(rows)
|
|
26
|
+
@selected_index = clamp_index(selected_index)
|
|
27
|
+
@keymap = keymap
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_key(event)
|
|
31
|
+
return nil if rows.empty?
|
|
32
|
+
|
|
33
|
+
case Charming.key_of(event)
|
|
34
|
+
when :enter then [:selected, selected_row]
|
|
35
|
+
else super
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def handle_mouse(event)
|
|
40
|
+
return nil if rows.empty?
|
|
41
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
42
|
+
|
|
43
|
+
clicked = event.y - HEADER_HEIGHT
|
|
44
|
+
return nil if clicked.negative? || clicked >= rows.length
|
|
45
|
+
|
|
46
|
+
@selected_index = clicked
|
|
47
|
+
:handled
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def selected_row
|
|
51
|
+
rows[selected_index]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render
|
|
55
|
+
return "(empty table)" if header.empty? && rows.empty?
|
|
56
|
+
|
|
57
|
+
normalized = rows.map { |row| normalize_row(row) }
|
|
58
|
+
lines = TTY::Table.new(header: header, rows: normalized)
|
|
59
|
+
.render(:unicode)
|
|
60
|
+
.lines(chomp: true)
|
|
61
|
+
|
|
62
|
+
compact_layout(lines)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def normalize_row(row)
|
|
68
|
+
cells = case row
|
|
69
|
+
when Hash then row.values
|
|
70
|
+
when String then [row]
|
|
71
|
+
else Array(row)
|
|
72
|
+
end
|
|
73
|
+
return cells if header.length <= 1 || cells.length <= header.length
|
|
74
|
+
|
|
75
|
+
kept = cells.first(header.length - 1)
|
|
76
|
+
merged = cells[(header.length - 1)..].join(" ")
|
|
77
|
+
kept + [merged]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def compact_layout(lines)
|
|
81
|
+
return lines.join("\n") if lines.length < 4
|
|
82
|
+
|
|
83
|
+
top, header_line, _separator, *rest = lines
|
|
84
|
+
body = rest.first(rows.length)
|
|
85
|
+
bottom = rest[rows.length]
|
|
86
|
+
|
|
87
|
+
highlighted = body.each_with_index.map do |line, index|
|
|
88
|
+
(index == selected_index) ? "\e[7m#{line}\e[m" : line
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
[top, header_line, *highlighted, bottom].compact.join("\n")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def move_up
|
|
95
|
+
@selected_index -= 1 if selected_index.positive?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def move_down
|
|
99
|
+
@selected_index += 1 if selected_index < rows.length - 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def move_home
|
|
103
|
+
@selected_index = 0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def move_end
|
|
107
|
+
@selected_index = rows.length - 1
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def clamp_index(value)
|
|
111
|
+
return 0 if rows.empty?
|
|
112
|
+
|
|
113
|
+
value.to_i.clamp(0, rows.length - 1)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class TextArea < Component
|
|
7
|
+
attr_reader :value, :cursor, :offset, :preferred_column
|
|
8
|
+
|
|
9
|
+
def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
|
|
10
|
+
super()
|
|
11
|
+
@value = value.dup
|
|
12
|
+
@placeholder = placeholder
|
|
13
|
+
@width = width
|
|
14
|
+
@height = height
|
|
15
|
+
@cursor = cursor || @value.length
|
|
16
|
+
@offset = offset
|
|
17
|
+
@preferred_column = preferred_column
|
|
18
|
+
clamp_position
|
|
19
|
+
ensure_cursor_visible
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def handle_key(event)
|
|
23
|
+
key = Charming.key_of(event)
|
|
24
|
+
return :handled if newline_event?(event) && insert("\n")
|
|
25
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
26
|
+
|
|
27
|
+
case key
|
|
28
|
+
when :left then move_left
|
|
29
|
+
when :right then move_right
|
|
30
|
+
when :up then move_up
|
|
31
|
+
when :down then move_down
|
|
32
|
+
when :home then move_home
|
|
33
|
+
when :end then move_end
|
|
34
|
+
when :backspace then delete_before_cursor
|
|
35
|
+
when :delete then delete_at_cursor
|
|
36
|
+
when :page_up then page_up
|
|
37
|
+
when :page_down then page_down
|
|
38
|
+
else return nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
:handled
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def render
|
|
45
|
+
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
attr_reader :placeholder, :width, :height
|
|
51
|
+
|
|
52
|
+
def newline_event?(event)
|
|
53
|
+
key = Charming.key_of(event)
|
|
54
|
+
return true if key == :enter && event.respond_to?(:shift) && event.shift
|
|
55
|
+
return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
|
|
56
|
+
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def character_event?(event)
|
|
61
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def printable?(char)
|
|
65
|
+
!char.match?(/[[:cntrl:]]/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def insert(text)
|
|
69
|
+
@value = value[0...cursor].to_s + text + value[cursor..].to_s
|
|
70
|
+
@cursor += text.length
|
|
71
|
+
reset_preferred_column
|
|
72
|
+
ensure_cursor_visible
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def move_left
|
|
76
|
+
@cursor -= 1 if cursor.positive?
|
|
77
|
+
reset_preferred_column
|
|
78
|
+
ensure_cursor_visible
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def move_right
|
|
82
|
+
@cursor += 1 if cursor < value.length
|
|
83
|
+
reset_preferred_column
|
|
84
|
+
ensure_cursor_visible
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def move_up
|
|
88
|
+
move_vertical(-1)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def move_down
|
|
92
|
+
move_vertical(+1)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def move_home
|
|
96
|
+
row, = cursor_position
|
|
97
|
+
@cursor = line_start(row)
|
|
98
|
+
reset_preferred_column
|
|
99
|
+
ensure_cursor_visible
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def move_end
|
|
103
|
+
row, = cursor_position
|
|
104
|
+
@cursor = line_start(row) + line_length(row)
|
|
105
|
+
reset_preferred_column
|
|
106
|
+
ensure_cursor_visible
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def delete_before_cursor
|
|
110
|
+
return if cursor.zero?
|
|
111
|
+
|
|
112
|
+
@value = value[0...(cursor - 1)].to_s + value[cursor..].to_s
|
|
113
|
+
@cursor -= 1
|
|
114
|
+
reset_preferred_column
|
|
115
|
+
ensure_cursor_visible
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def delete_at_cursor
|
|
119
|
+
return if cursor >= value.length
|
|
120
|
+
|
|
121
|
+
@value = value[0...cursor].to_s + value[(cursor + 1)..].to_s
|
|
122
|
+
reset_preferred_column
|
|
123
|
+
ensure_cursor_visible
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def page_up
|
|
127
|
+
@offset -= viewport_height
|
|
128
|
+
clamp_offset
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def page_down
|
|
132
|
+
@offset += viewport_height
|
|
133
|
+
clamp_offset
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def move_vertical(delta)
|
|
137
|
+
row, column = cursor_position
|
|
138
|
+
target_row = (row + delta).clamp(0, lines.length - 1)
|
|
139
|
+
@preferred_column ||= column
|
|
140
|
+
@cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
|
|
141
|
+
ensure_cursor_visible
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def reset_preferred_column
|
|
145
|
+
@preferred_column = cursor_position.last
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def cursor_position
|
|
149
|
+
before = value[0...cursor].to_s
|
|
150
|
+
row = before.count("\n")
|
|
151
|
+
last_newline = before.rindex("\n")
|
|
152
|
+
column = last_newline ? before.length - last_newline - 1 : before.length
|
|
153
|
+
[row, column]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def line_start(row)
|
|
157
|
+
lines.first(row).sum(&:length) + row
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def line_length(row)
|
|
161
|
+
lines.fetch(row, "").length
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def lines
|
|
165
|
+
value.empty? ? [""] : value.split("\n", -1)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def rendered_lines
|
|
169
|
+
return [cursor_marker + placeholder] if value.empty?
|
|
170
|
+
|
|
171
|
+
(value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def visible_lines
|
|
175
|
+
ensure_cursor_visible
|
|
176
|
+
rendered = rendered_lines.slice(offset, viewport_height) || []
|
|
177
|
+
return rendered unless height
|
|
178
|
+
|
|
179
|
+
rendered + Array.new([height - rendered.length, 0].max, "")
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def render_line(line)
|
|
183
|
+
return line unless width
|
|
184
|
+
|
|
185
|
+
clipped = UI.visible_slice(line, 0, width)
|
|
186
|
+
clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def ensure_cursor_visible
|
|
190
|
+
row, = cursor_position
|
|
191
|
+
@offset = row if row < offset
|
|
192
|
+
@offset = row - viewport_height + 1 if row >= offset + viewport_height
|
|
193
|
+
clamp_offset
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def clamp_position
|
|
197
|
+
@cursor = cursor.clamp(0, value.length)
|
|
198
|
+
clamp_offset
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def clamp_offset
|
|
202
|
+
@offset = offset.clamp(0, max_offset)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def max_offset
|
|
206
|
+
[lines.length - viewport_height, 0].max
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def viewport_height
|
|
210
|
+
height || lines.length
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def cursor_marker
|
|
214
|
+
"|"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
class TextInput < Component
|
|
7
|
+
include KeyboardHandler
|
|
8
|
+
|
|
9
|
+
# Maps editing keys (left/right/home/end/backspace/delete) to the instance
|
|
10
|
+
# methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
|
|
11
|
+
# maps to a method (e.g., :move_left) that adjusts cursor position or text content.
|
|
12
|
+
KEY_ACTIONS = {
|
|
13
|
+
left: :move_left,
|
|
14
|
+
right: :move_right,
|
|
15
|
+
home: :move_home,
|
|
16
|
+
end: :move_end,
|
|
17
|
+
backspace: :delete_before_cursor,
|
|
18
|
+
delete: :delete_at_cursor
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :value, :cursor
|
|
22
|
+
|
|
23
|
+
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
24
|
+
super()
|
|
25
|
+
@value = value.dup
|
|
26
|
+
@placeholder = placeholder
|
|
27
|
+
@width = width
|
|
28
|
+
@cursor = cursor || @value.length
|
|
29
|
+
clamp_position
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def handle_key(event)
|
|
33
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
34
|
+
|
|
35
|
+
super
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render
|
|
39
|
+
rendered = render_value
|
|
40
|
+
@width ? style.width(@width).render(rendered) : rendered
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
attr_reader :placeholder
|
|
46
|
+
|
|
47
|
+
def character_event?(event)
|
|
48
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def printable?(char)
|
|
52
|
+
!char.match?(/[[:cntrl:]]/)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def insert(char)
|
|
56
|
+
@value = value[0...cursor] + char + value[cursor..]
|
|
57
|
+
@cursor += char.length
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def move_left
|
|
61
|
+
@cursor -= 1 if cursor.positive?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def move_right
|
|
65
|
+
@cursor += 1 if cursor < value.length
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def move_home
|
|
69
|
+
@cursor = 0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def move_end
|
|
73
|
+
@cursor = value.length
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_before_cursor
|
|
77
|
+
return if cursor.zero?
|
|
78
|
+
|
|
79
|
+
@value = value[0...(cursor - 1)] + value[cursor..]
|
|
80
|
+
@cursor -= 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def delete_at_cursor
|
|
84
|
+
return if cursor >= value.length
|
|
85
|
+
|
|
86
|
+
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_value
|
|
90
|
+
return cursor_marker + placeholder if value.empty?
|
|
91
|
+
|
|
92
|
+
value[0...cursor] + cursor_marker + value[cursor..]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def cursor_marker
|
|
96
|
+
"|"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def clamp_position
|
|
100
|
+
@cursor = cursor.clamp(0, value.length)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|