charming 0.1.0 → 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 +38 -378
- data/lib/charming/application.rb +14 -3
- data/lib/charming/{application_model.rb → application_state.rb} +3 -3
- data/lib/charming/cli.rb +62 -3
- 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 +46 -448
- data/lib/charming/database_commands.rb +103 -0
- data/lib/charming/database_installer.rb +152 -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/focus.rb +58 -2
- data/lib/charming/generators/app_file_generator.rb +13 -0
- data/lib/charming/generators/app_generator.rb +147 -45
- 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 -14
- data/lib/charming/generators/model_generator.rb +128 -0
- data/lib/charming/generators/name.rb +10 -4
- data/lib/charming/generators/screen_generator.rb +84 -52
- 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 +26 -13
- data/lib/charming/internal/renderer/differential.rb +17 -3
- 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 +62 -115
- 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 +56 -0
- data/lib/charming/presentation/components/form/builder.rb +62 -0
- data/lib/charming/presentation/components/form/confirm.rb +69 -0
- data/lib/charming/presentation/components/form/field.rb +121 -0
- data/lib/charming/presentation/components/form/input.rb +71 -0
- data/lib/charming/presentation/components/form/note.rb +41 -0
- data/lib/charming/presentation/components/form/select.rb +112 -0
- data/lib/charming/presentation/components/form/textarea.rb +86 -0
- data/lib/charming/presentation/components/form.rb +156 -0
- data/lib/charming/presentation/components/keyboard_handler.rb +58 -0
- data/lib/charming/presentation/components/list.rb +132 -0
- data/lib/charming/presentation/components/markdown.rb +31 -0
- data/lib/charming/presentation/components/modal.rb +64 -0
- data/lib/charming/presentation/components/progressbar.rb +70 -0
- data/lib/charming/presentation/components/spinner.rb +49 -0
- data/lib/charming/presentation/components/table.rb +143 -0
- data/lib/charming/presentation/components/text_area.rb +267 -0
- data/lib/charming/presentation/components/text_input.rb +129 -0
- data/lib/charming/presentation/components/viewport.rb +272 -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/layout.rb +43 -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 +113 -0
- data/lib/charming/presentation/markdown/syntax_highlighter.rb +79 -0
- data/lib/charming/presentation/markdown.rb +11 -0
- data/lib/charming/presentation/template_view.rb +34 -0
- data/lib/charming/presentation/templates/erb_handler.rb +15 -0
- data/lib/charming/presentation/templates.rb +68 -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.rb +35 -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 +213 -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 +91 -0
- data/lib/charming/presentation/view.rb +135 -0
- data/lib/charming/runtime.rb +9 -7
- data/lib/charming/screen.rb +5 -1
- data/lib/charming/tasks/inline_executor.rb +37 -0
- data/lib/charming/tasks/task.rb +12 -0
- data/lib/charming/tasks/threaded_executor.rb +51 -0
- data/lib/charming/version.rb +1 -1
- data/lib/charming.rb +17 -0
- metadata +170 -36
- 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/generators/app_generator/app_spec_templates.rb +0 -86
- data/lib/charming/generators/app_generator/basic_templates.rb +0 -69
- data/lib/charming/generators/app_generator/component_templates.rb +0 -36
- data/lib/charming/generators/app_generator/controller_template.rb +0 -69
- data/lib/charming/generators/app_generator/layout_template.rb +0 -160
- data/lib/charming/generators/app_generator/model_templates.rb +0 -30
- data/lib/charming/generators/app_generator/screen_spec_templates.rb +0 -70
- data/lib/charming/generators/app_generator/view_template.rb +0 -90
- 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/task_executor.rb +0 -62
- 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,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-table"
|
|
4
|
+
|
|
5
|
+
module Charming
|
|
6
|
+
module Presentation
|
|
7
|
+
module Components
|
|
8
|
+
# Table renders tabular data with a header row, a selected row highlight, and keyboard
|
|
9
|
+
# navigation. Mouse clicks within the body area also select rows. The table is rendered
|
|
10
|
+
# via tty-table and the selected row is overlaid with reverse-video ANSI styling.
|
|
11
|
+
class Table < Component
|
|
12
|
+
include KeyboardHandler
|
|
13
|
+
|
|
14
|
+
# Maps navigation keys to the instance methods that move the selection. Shared with
|
|
15
|
+
# List and Viewport via KeyboardHandler.
|
|
16
|
+
KEY_ACTIONS = {
|
|
17
|
+
up: :move_up,
|
|
18
|
+
down: :move_down,
|
|
19
|
+
home: :move_home,
|
|
20
|
+
end: :move_end
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
# Number of terminal rows occupied by the table's top border and header line. Used by
|
|
24
|
+
# the mouse handler to translate absolute row coordinates to body rows.
|
|
25
|
+
HEADER_HEIGHT = 2
|
|
26
|
+
|
|
27
|
+
# The header row, the body rows, and the currently selected row index, respectively.
|
|
28
|
+
attr_reader :header, :rows, :selected_index
|
|
29
|
+
|
|
30
|
+
# *header* is an array of column labels. *rows* is the array of body rows (each either a
|
|
31
|
+
# String, an Array, or a Hash of column-value pairs). *selected_index* defaults to 0.
|
|
32
|
+
# *keymap* selects the keybinding style (`:vim` enables h/j/k/l → left/down/up/right).
|
|
33
|
+
def initialize(header:, rows: [], selected_index: 0, keymap: :vim)
|
|
34
|
+
super()
|
|
35
|
+
@header = Array(header).map(&:to_s)
|
|
36
|
+
@rows = Array(rows)
|
|
37
|
+
@selected_index = clamp_index(selected_index)
|
|
38
|
+
@keymap = keymap
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Handles key events. Returns `[:selected, row]` on Enter; otherwise delegates to the
|
|
42
|
+
# KeyboardHandler for navigation keys.
|
|
43
|
+
def handle_key(event)
|
|
44
|
+
return nil if rows.empty?
|
|
45
|
+
|
|
46
|
+
case Charming.key_of(event)
|
|
47
|
+
when :enter then [:selected, selected_row]
|
|
48
|
+
else super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Handles mouse events: a click within the body area selects the clicked row.
|
|
53
|
+
# Returns :handled on a successful click.
|
|
54
|
+
def handle_mouse(event)
|
|
55
|
+
return nil if rows.empty?
|
|
56
|
+
return nil unless event.respond_to?(:click?) && event.click?
|
|
57
|
+
|
|
58
|
+
clicked = event.y - HEADER_HEIGHT
|
|
59
|
+
return nil if clicked.negative? || clicked >= rows.length
|
|
60
|
+
|
|
61
|
+
@selected_index = clicked
|
|
62
|
+
:handled
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the currently selected row, or nil when the table is empty.
|
|
66
|
+
def selected_row
|
|
67
|
+
rows[selected_index]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Renders the table to a string. Returns a placeholder when both header and rows are empty.
|
|
71
|
+
def render
|
|
72
|
+
return "(empty table)" if header.empty? && rows.empty?
|
|
73
|
+
|
|
74
|
+
normalized = rows.map { |row| normalize_row(row) }
|
|
75
|
+
lines = TTY::Table.new(header: header, rows: normalized)
|
|
76
|
+
.render(:unicode)
|
|
77
|
+
.lines(chomp: true)
|
|
78
|
+
|
|
79
|
+
compact_layout(lines)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Coerces a *row* (Hash / String / Array) into a flat cell array matching the header.
|
|
85
|
+
# Excess cells are merged into the last column with a space separator.
|
|
86
|
+
def normalize_row(row)
|
|
87
|
+
cells = case row
|
|
88
|
+
when Hash then row.values
|
|
89
|
+
when String then [row]
|
|
90
|
+
else Array(row)
|
|
91
|
+
end
|
|
92
|
+
return cells if header.length <= 1 || cells.length <= header.length
|
|
93
|
+
|
|
94
|
+
kept = cells.first(header.length - 1)
|
|
95
|
+
merged = cells[(header.length - 1)..].join(" ")
|
|
96
|
+
kept + [merged]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Applies the selected-row highlight and trims unused body rows below the actual row count.
|
|
100
|
+
def compact_layout(lines)
|
|
101
|
+
return lines.join("\n") if lines.length < 4
|
|
102
|
+
|
|
103
|
+
top, header_line, _separator, *rest = lines
|
|
104
|
+
body = rest.first(rows.length)
|
|
105
|
+
bottom = rest[rows.length]
|
|
106
|
+
|
|
107
|
+
highlighted = body.each_with_index.map do |line, index|
|
|
108
|
+
(index == selected_index) ? "\e[7m#{line}\e[m" : line
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
[top, header_line, *highlighted, bottom].compact.join("\n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Moves the selection up one row.
|
|
115
|
+
def move_up
|
|
116
|
+
@selected_index -= 1 if selected_index.positive?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Moves the selection down one row.
|
|
120
|
+
def move_down
|
|
121
|
+
@selected_index += 1 if selected_index < rows.length - 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Moves the selection to the first row.
|
|
125
|
+
def move_home
|
|
126
|
+
@selected_index = 0
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Moves the selection to the last row.
|
|
130
|
+
def move_end
|
|
131
|
+
@selected_index = rows.length - 1
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Clamps *value* to the valid row range, defaulting to 0 when the table is empty.
|
|
135
|
+
def clamp_index(value)
|
|
136
|
+
return 0 if rows.empty?
|
|
137
|
+
|
|
138
|
+
value.to_i.clamp(0, rows.length - 1)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# TextArea is a multi-line text editor component. Supports character insertion (with
|
|
7
|
+
# newline insertion via Shift+Enter or Ctrl+J), cursor movement (left/right/up/down,
|
|
8
|
+
# home/end, page up/down), deletion (backspace/delete), and scrolling for long buffers.
|
|
9
|
+
# Vertical movement preserves a "preferred column" so left/right navigation feels stable.
|
|
10
|
+
class TextArea < Component
|
|
11
|
+
# The current text value, cursor byte offset, top-visible row offset, and remembered
|
|
12
|
+
# column for vertical navigation, respectively.
|
|
13
|
+
attr_reader :value, :cursor, :offset, :preferred_column
|
|
14
|
+
|
|
15
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty. *width* and
|
|
16
|
+
# *height* constrain the rendered output. *cursor* defaults to the end of the value.
|
|
17
|
+
# *offset* is the top-visible row. *preferred_column* is the column to resume at on
|
|
18
|
+
# vertical movement (defaults to the current column on first use).
|
|
19
|
+
def initialize(value: "", placeholder: "", width: nil, height: nil, cursor: nil, offset: 0, preferred_column: nil)
|
|
20
|
+
super()
|
|
21
|
+
@value = value.dup
|
|
22
|
+
@placeholder = placeholder
|
|
23
|
+
@width = width
|
|
24
|
+
@height = height
|
|
25
|
+
@cursor = cursor || @value.length
|
|
26
|
+
@offset = offset
|
|
27
|
+
@preferred_column = preferred_column
|
|
28
|
+
clamp_position
|
|
29
|
+
ensure_cursor_visible
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Routes key events to the appropriate cursor/text mutation. Returns :handled when the
|
|
33
|
+
# event was consumed, nil otherwise.
|
|
34
|
+
def handle_key(event)
|
|
35
|
+
key = Charming.key_of(event)
|
|
36
|
+
return :handled if newline_event?(event) && insert("\n")
|
|
37
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
38
|
+
|
|
39
|
+
case key
|
|
40
|
+
when :left then move_left
|
|
41
|
+
when :right then move_right
|
|
42
|
+
when :up then move_up
|
|
43
|
+
when :down then move_down
|
|
44
|
+
when :home then move_home
|
|
45
|
+
when :end then move_end
|
|
46
|
+
when :backspace then delete_before_cursor
|
|
47
|
+
when :delete then delete_at_cursor
|
|
48
|
+
when :page_up then page_up
|
|
49
|
+
when :page_down then page_down
|
|
50
|
+
else return nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
:handled
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renders the visible portion of the text buffer (scrolled to `offset`), with each
|
|
57
|
+
# visible line either clipped to `width` or padded to it.
|
|
58
|
+
def render
|
|
59
|
+
visible_lines.map { |line| render_line(line) }.join("\n")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
attr_reader :placeholder, :width, :height
|
|
65
|
+
|
|
66
|
+
# True when the event represents an explicit newline request: Shift+Enter or Ctrl+J.
|
|
67
|
+
def newline_event?(event)
|
|
68
|
+
key = Charming.key_of(event)
|
|
69
|
+
return true if key == :enter && event.respond_to?(:shift) && event.shift
|
|
70
|
+
return true if key == :j && event.respond_to?(:ctrl) && event.ctrl
|
|
71
|
+
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# True when *event* carries a single printable character.
|
|
76
|
+
def character_event?(event)
|
|
77
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# True when *char* is not a control character.
|
|
81
|
+
def printable?(char)
|
|
82
|
+
!char.match?(/[[:cntrl:]]/)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Inserts *text* at the cursor, advances the cursor by its length, resets the preferred
|
|
86
|
+
# column, and ensures the cursor remains visible.
|
|
87
|
+
def insert(text)
|
|
88
|
+
@value = value[0...cursor].to_s + text + value[cursor..].to_s
|
|
89
|
+
@cursor += text.length
|
|
90
|
+
reset_preferred_column
|
|
91
|
+
ensure_cursor_visible
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Moves the cursor one character left.
|
|
95
|
+
def move_left
|
|
96
|
+
@cursor -= 1 if cursor.positive?
|
|
97
|
+
reset_preferred_column
|
|
98
|
+
ensure_cursor_visible
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Moves the cursor one character right.
|
|
102
|
+
def move_right
|
|
103
|
+
@cursor += 1 if cursor < value.length
|
|
104
|
+
reset_preferred_column
|
|
105
|
+
ensure_cursor_visible
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Moves the cursor up one line while preserving the preferred column.
|
|
109
|
+
def move_up
|
|
110
|
+
move_vertical(-1)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Moves the cursor down one line while preserving the preferred column.
|
|
114
|
+
def move_down
|
|
115
|
+
move_vertical(+1)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Moves the cursor to the start of the current line.
|
|
119
|
+
def move_home
|
|
120
|
+
row, = cursor_position
|
|
121
|
+
@cursor = line_start(row)
|
|
122
|
+
reset_preferred_column
|
|
123
|
+
ensure_cursor_visible
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Moves the cursor to the end of the current line.
|
|
127
|
+
def move_end
|
|
128
|
+
row, = cursor_position
|
|
129
|
+
@cursor = line_start(row) + line_length(row)
|
|
130
|
+
reset_preferred_column
|
|
131
|
+
ensure_cursor_visible
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
135
|
+
def delete_before_cursor
|
|
136
|
+
return if cursor.zero?
|
|
137
|
+
|
|
138
|
+
@value = value[0...(cursor - 1)].to_s + value[cursor..].to_s
|
|
139
|
+
@cursor -= 1
|
|
140
|
+
reset_preferred_column
|
|
141
|
+
ensure_cursor_visible
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
145
|
+
def delete_at_cursor
|
|
146
|
+
return if cursor >= value.length
|
|
147
|
+
|
|
148
|
+
@value = value[0...cursor].to_s + value[(cursor + 1)..].to_s
|
|
149
|
+
reset_preferred_column
|
|
150
|
+
ensure_cursor_visible
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Scrolls the buffer up by one viewport height.
|
|
154
|
+
def page_up
|
|
155
|
+
@offset -= viewport_height
|
|
156
|
+
clamp_offset
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Scrolls the buffer down by one viewport height.
|
|
160
|
+
def page_down
|
|
161
|
+
@offset += viewport_height
|
|
162
|
+
clamp_offset
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Moves the cursor vertically by *delta* rows. Stays within the line count and uses
|
|
166
|
+
# `preferred_column` so up/down movement feels stable on short lines.
|
|
167
|
+
def move_vertical(delta)
|
|
168
|
+
row, column = cursor_position
|
|
169
|
+
target_row = (row + delta).clamp(0, lines.length - 1)
|
|
170
|
+
@preferred_column ||= column
|
|
171
|
+
@cursor = line_start(target_row) + [@preferred_column, line_length(target_row)].min
|
|
172
|
+
ensure_cursor_visible
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Sets the preferred column to the current column (called when horizontal movement happens).
|
|
176
|
+
def reset_preferred_column
|
|
177
|
+
@preferred_column = cursor_position.last
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Returns the cursor's current position as `[row, column]`, where row is the zero-based
|
|
181
|
+
# line index and column is the character offset within that line.
|
|
182
|
+
def cursor_position
|
|
183
|
+
before = value[0...cursor].to_s
|
|
184
|
+
row = before.count("\n")
|
|
185
|
+
last_newline = before.rindex("\n")
|
|
186
|
+
column = last_newline ? before.length - last_newline - 1 : before.length
|
|
187
|
+
[row, column]
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Returns the byte offset where line *row* begins in the value.
|
|
191
|
+
def line_start(row)
|
|
192
|
+
lines.first(row).sum(&:length) + row
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Returns the character length of the line at *row* (empty string when row is past the end).
|
|
196
|
+
def line_length(row)
|
|
197
|
+
lines.fetch(row, "").length
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Splits the value into an array of lines (preserving trailing empty lines).
|
|
201
|
+
def lines
|
|
202
|
+
value.empty? ? [""] : value.split("\n", -1)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Returns the rendered lines (with cursor marker inserted) before viewport slicing.
|
|
206
|
+
def rendered_lines
|
|
207
|
+
return [cursor_marker + placeholder] if value.empty?
|
|
208
|
+
|
|
209
|
+
(value[0...cursor].to_s + cursor_marker + value[cursor..].to_s).split("\n", -1)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Returns the lines that should be visible in the current viewport, padded to *height*
|
|
213
|
+
# with empty strings when the buffer is shorter.
|
|
214
|
+
def visible_lines
|
|
215
|
+
ensure_cursor_visible
|
|
216
|
+
rendered = rendered_lines.slice(offset, viewport_height) || []
|
|
217
|
+
return rendered unless height
|
|
218
|
+
|
|
219
|
+
rendered + Array.new([height - rendered.length, 0].max, "")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Renders a single line, clipping to *width* and padding with spaces.
|
|
223
|
+
def render_line(line)
|
|
224
|
+
return line unless width
|
|
225
|
+
|
|
226
|
+
clipped = UI.visible_slice(line, 0, width)
|
|
227
|
+
clipped + (" " * [width - UI::Width.measure(clipped), 0].max)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Adjusts the top-visible offset so the cursor row is in view. Scrolling is performed
|
|
231
|
+
# one row at a time when needed.
|
|
232
|
+
def ensure_cursor_visible
|
|
233
|
+
row, = cursor_position
|
|
234
|
+
@offset = row if row < offset
|
|
235
|
+
@offset = row - viewport_height + 1 if row >= offset + viewport_height
|
|
236
|
+
clamp_offset
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Clamps the cursor and offset to valid bounds.
|
|
240
|
+
def clamp_position
|
|
241
|
+
@cursor = cursor.clamp(0, value.length)
|
|
242
|
+
clamp_offset
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Clamps the offset to the valid range `[0, max_offset]`.
|
|
246
|
+
def clamp_offset
|
|
247
|
+
@offset = offset.clamp(0, max_offset)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Returns the maximum allowed offset (so the bottom of the buffer is reachable).
|
|
251
|
+
def max_offset
|
|
252
|
+
[lines.length - viewport_height, 0].max
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Returns the visible row count (the configured *height* or the buffer's line count).
|
|
256
|
+
def viewport_height
|
|
257
|
+
height || lines.length
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The literal character used to mark the cursor position in `rendered_lines`.
|
|
261
|
+
def cursor_marker
|
|
262
|
+
"|"
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Charming
|
|
4
|
+
module Presentation
|
|
5
|
+
module Components
|
|
6
|
+
# TextInput is a single-line text editor component. Supports printable character insertion,
|
|
7
|
+
# cursor movement (left/right/home/end), and deletion (backspace/delete). The component
|
|
8
|
+
# exposes its `value` and `cursor` positions as reader methods; when an explicit `width:`
|
|
9
|
+
# is given, the rendered output is padded to that width via a UI::Style.
|
|
10
|
+
class TextInput < Component
|
|
11
|
+
include KeyboardHandler
|
|
12
|
+
|
|
13
|
+
# Maps editing keys (left/right/home/end/backspace/delete) to the instance
|
|
14
|
+
# methods they dispatch via KeyboardHandler. Each symbol key (e.g., :left)
|
|
15
|
+
# maps to a method (e.g., :move_left) that adjusts cursor position or text content.
|
|
16
|
+
KEY_ACTIONS = {
|
|
17
|
+
left: :move_left,
|
|
18
|
+
right: :move_right,
|
|
19
|
+
home: :move_home,
|
|
20
|
+
end: :move_end,
|
|
21
|
+
backspace: :delete_before_cursor,
|
|
22
|
+
delete: :delete_at_cursor
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
# The current input string and the byte offset of the cursor within it.
|
|
26
|
+
attr_reader :value, :cursor
|
|
27
|
+
|
|
28
|
+
# *value* is the initial text. *placeholder* is shown when the value is empty.
|
|
29
|
+
# *width* optionally constrains the rendered output width; *cursor* defaults to the end.
|
|
30
|
+
def initialize(value: "", placeholder: "", width: nil, cursor: nil)
|
|
31
|
+
super()
|
|
32
|
+
@value = value.dup
|
|
33
|
+
@placeholder = placeholder
|
|
34
|
+
@width = width
|
|
35
|
+
@cursor = cursor || @value.length
|
|
36
|
+
clamp_position
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Handles key events. Inserts printable characters, otherwise dispatches via KEY_ACTIONS.
|
|
40
|
+
# Returns :handled when the event was consumed, nil otherwise.
|
|
41
|
+
def handle_key(event)
|
|
42
|
+
return :handled if character_event?(event) && insert(event.char)
|
|
43
|
+
|
|
44
|
+
super
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Renders the value with a cursor marker. When *width* was given at construction, the
|
|
48
|
+
# output is padded to that width via the configured style.
|
|
49
|
+
def render
|
|
50
|
+
rendered = render_value
|
|
51
|
+
@width ? style.width(@width).render(rendered) : rendered
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :placeholder
|
|
57
|
+
|
|
58
|
+
# True when *event* carries a single printable character that should be inserted.
|
|
59
|
+
def character_event?(event)
|
|
60
|
+
event.respond_to?(:char) && event.char && event.char.length == 1 && printable?(event.char)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# True when *char* is not a control character (and therefore safe to insert).
|
|
64
|
+
def printable?(char)
|
|
65
|
+
!char.match?(/[[:cntrl:]]/)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Inserts *char* at the cursor and advances the cursor by its byte length.
|
|
69
|
+
def insert(char)
|
|
70
|
+
@value = value[0...cursor] + char + value[cursor..]
|
|
71
|
+
@cursor += char.length
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Moves the cursor one position left, when possible.
|
|
75
|
+
def move_left
|
|
76
|
+
@cursor -= 1 if cursor.positive?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Moves the cursor one position right, when possible.
|
|
80
|
+
def move_right
|
|
81
|
+
@cursor += 1 if cursor < value.length
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Moves the cursor to the start of the value.
|
|
85
|
+
def move_home
|
|
86
|
+
@cursor = 0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Moves the cursor to the end of the value.
|
|
90
|
+
def move_end
|
|
91
|
+
@cursor = value.length
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Deletes the character before the cursor (backspace behavior).
|
|
95
|
+
def delete_before_cursor
|
|
96
|
+
return if cursor.zero?
|
|
97
|
+
|
|
98
|
+
@value = value[0...(cursor - 1)] + value[cursor..]
|
|
99
|
+
@cursor -= 1
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Deletes the character at the cursor (delete-key behavior).
|
|
103
|
+
def delete_at_cursor
|
|
104
|
+
return if cursor >= value.length
|
|
105
|
+
|
|
106
|
+
@value = value[0...cursor] + value[(cursor + 1)..]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Renders the value with a "|" cursor marker at the current position. When the value is
|
|
110
|
+
# empty, the placeholder is rendered instead, preceded by the cursor marker.
|
|
111
|
+
def render_value
|
|
112
|
+
return cursor_marker + placeholder if value.empty?
|
|
113
|
+
|
|
114
|
+
value[0...cursor] + cursor_marker + value[cursor..]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The literal character used to mark the cursor position in `render`.
|
|
118
|
+
def cursor_marker
|
|
119
|
+
"|"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Clamps the cursor to the valid range `[0, value.length]`.
|
|
123
|
+
def clamp_position
|
|
124
|
+
@cursor = cursor.clamp(0, value.length)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|