ratamin 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/release-gem/SKILL.md +11 -9
- data/CHANGELOG.md +25 -1
- data/README.md +25 -16
- data/lib/ratamin/app.rb +49 -17
- data/lib/ratamin/array_data_source.rb +5 -0
- data/lib/ratamin/column.rb +1 -1
- data/lib/ratamin/data_source.rb +5 -0
- data/lib/ratamin/form_state.rb +99 -40
- data/lib/ratamin/form_view.rb +75 -22
- data/lib/ratamin/railtie.rb +0 -3
- data/lib/ratamin/scope_data_source.rb +28 -1
- data/lib/ratamin/table_view.rb +0 -4
- data/lib/ratamin/version.rb +1 -1
- data/lib/ratamin.rb +2 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 443a14265f91ecb7a42d6ba90bfe3266fb6684978f4c2d9b323fe676969d2c11
|
|
4
|
+
data.tar.gz: 5227b26d004cad5fa090a7eff43947d41a6812e5e7f28a02b8633583a56a3a75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f53c415e467dfeb6cff17ca9bb584ee9b6248c8fb3243037c8ee87ab08b48134f881360e51a575e2186163da8ef3f9c74314c40d7812bc136132c731b85ed57a
|
|
7
|
+
data.tar.gz: 665b48695e5b0c74da01683528aa9c774676a83ca61bc5ad028eabd3641e6bd1fa8fb151397777f1e2e9daecdd9469809f73692a629d529927ff214a47a42b5f
|
|
@@ -8,27 +8,29 @@ Release the gem. If $ARGUMENTS is provided, that is the new version number. Othe
|
|
|
8
8
|
|
|
9
9
|
Steps:
|
|
10
10
|
|
|
11
|
-
1. **Confirm version**: Read `lib/ratamin/version.rb`. If $ARGUMENTS differs from the current version, update the file
|
|
11
|
+
1. **Confirm version**: Read `lib/ratamin/version.rb`. If $ARGUMENTS differs from the current version, update the file.
|
|
12
12
|
|
|
13
|
-
2. **Run
|
|
13
|
+
2. **Bundle**: Run `bundle` to update `Gemfile.lock` with the new version.
|
|
14
14
|
|
|
15
|
-
3. **
|
|
15
|
+
3. **Run tests**: `bundle exec rspec` — abort if any tests fail.
|
|
16
|
+
|
|
17
|
+
4. **Update CHANGELOG.md**: Add a new section for this version at the top (below the `## [Unreleased]` header if present), using Keep a Changelog format:
|
|
16
18
|
```
|
|
17
19
|
## [X.Y.Z] - YYYY-MM-DD
|
|
18
20
|
### Added / Changed / Fixed
|
|
19
21
|
- ...
|
|
20
22
|
```
|
|
21
|
-
Ask the user what to put in the changelog if there is nothing obvious from recent commits. Commit
|
|
23
|
+
Ask the user what to put in the changelog if there is nothing obvious from recent commits. Commit `lib/ratamin/version.rb`, `Gemfile.lock`, and `CHANGELOG.md` together in a single commit.
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
5. **Build the gem**: `gem build ratamin.gemspec`
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
6. **Push to RubyGems**: `gem push ratamin-X.Y.Z.gem`
|
|
26
28
|
|
|
27
|
-
|
|
29
|
+
7. **Create git tag**: `git tag vX.Y.Z`
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
8. **Push commits and tag**: `git push origin main --tags`
|
|
30
32
|
|
|
31
|
-
|
|
33
|
+
9. **Create GitHub Release**: Extract the release notes for this version from CHANGELOG.md and run:
|
|
32
34
|
```
|
|
33
35
|
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file <(extracted notes)
|
|
34
36
|
```
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.3.0] - 2026-03-15
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Create new records with `n` key in table view
|
|
14
|
+
- `create_row(attributes)` method on the `DataSource` protocol
|
|
15
|
+
- Two-mode form UX: select mode (vim-style navigation) and inline edit mode
|
|
16
|
+
- Unsaved changes confirmation dialog when leaving a dirty form
|
|
17
|
+
- Ctrl+E opens `$EDITOR` for any field type (not just `:text`)
|
|
18
|
+
- Vim-style `j`/`k` field navigation in form select mode
|
|
19
|
+
- PageUp/PageDown pagination (replaces `n`/`p`)
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- Form title is now dynamic ("New Record" vs "Edit Record")
|
|
23
|
+
- Removed duplicate help text from form body (shown only in status bar)
|
|
24
|
+
- Removed dead code from `TableView#compute_widths`
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- SQL logging no longer interferes with TUI in dev mode (suppressed for entire session)
|
|
28
|
+
- Esc in edit mode now discards field changes (Enter confirms)
|
|
29
|
+
- Save errors display inline instead of crashing
|
|
30
|
+
- Stale in-memory data no longer shown after failed save (record reloaded)
|
|
31
|
+
- Column labels use raw names instead of capitalized
|
|
32
|
+
|
|
10
33
|
## [0.2.0] - 2026-03-14
|
|
11
34
|
|
|
12
35
|
### Added
|
|
@@ -35,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
35
58
|
- RSpec test suite with in-memory SQLite for ActiveRecord integration tests
|
|
36
59
|
- GitHub Actions CI on Ruby 3.3, 3.4, and 4.0
|
|
37
60
|
|
|
38
|
-
[Unreleased]: https://github.com/onyxblade/ratamin/compare/v0.
|
|
61
|
+
[Unreleased]: https://github.com/onyxblade/ratamin/compare/v0.3.0...HEAD
|
|
62
|
+
[0.3.0]: https://github.com/onyxblade/ratamin/compare/v0.2.0...v0.3.0
|
|
39
63
|
[0.2.0]: https://github.com/onyxblade/ratamin/compare/v0.1.0...v0.2.0
|
|
40
64
|
[0.1.0]: https://github.com/onyxblade/ratamin/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -14,16 +14,9 @@ gem "ratamin"
|
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
16
16
|
|
|
17
|
-
### Rails integration (
|
|
17
|
+
### Rails integration (automatic)
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
```ruby
|
|
22
|
-
# config/initializers/ratamin.rb
|
|
23
|
-
require "ratamin/railtie"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Then define which columns to show in your model:
|
|
19
|
+
In a Rails app, `Model.ratamin` and `scope.ratamin` are available automatically — no configuration needed. Just add the gem and define which columns to show in your model:
|
|
27
20
|
|
|
28
21
|
```ruby
|
|
29
22
|
class User < ApplicationRecord
|
|
@@ -44,7 +37,7 @@ User.where(role: "admin").ratamin # filtered scope
|
|
|
44
37
|
|
|
45
38
|
Column type (`:text` vs `:string`) and editability (`id`, `created_at`, `updated_at` are read-only by default) are inferred from the schema. Any option explicitly passed to `column` overrides the inferred default.
|
|
46
39
|
|
|
47
|
-
|
|
40
|
+
To opt out of the Rails integration, use `require: false` in your Gemfile and `require "ratamin"` manually — `scope.ratamin` will then be unavailable, but the core API still works.
|
|
48
41
|
|
|
49
42
|
### Manual usage (without Railtie)
|
|
50
43
|
|
|
@@ -92,19 +85,35 @@ Ratamin::App.new(ds).run
|
|
|
92
85
|
| `k` / `Up` | Previous row |
|
|
93
86
|
| `g` | First row |
|
|
94
87
|
| `G` | Last row |
|
|
95
|
-
| `Enter` | Edit selected record |
|
|
88
|
+
| `l` / `Enter` | Edit selected record |
|
|
96
89
|
| `r` | Reload data |
|
|
97
|
-
| `
|
|
90
|
+
| `PageDown` | Next page |
|
|
91
|
+
| `PageUp` | Previous page |
|
|
92
|
+
| `Esc` / `q` | Quit |
|
|
98
93
|
|
|
99
|
-
### Form view
|
|
94
|
+
### Form view — select mode
|
|
100
95
|
|
|
101
96
|
| Key | Action |
|
|
102
97
|
|-----|--------|
|
|
98
|
+
| `j` / `Down` | Next field |
|
|
99
|
+
| `k` / `Up` | Previous field |
|
|
103
100
|
| `Tab` | Next field |
|
|
104
101
|
| `Shift+Tab` | Previous field |
|
|
105
|
-
| `Enter` |
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
102
|
+
| `l` / `Enter` | Edit field |
|
|
103
|
+
| `Ctrl+E` | Open `$EDITOR` |
|
|
104
|
+
| `s` | Save |
|
|
105
|
+
| `h` / `Esc` | Cancel (back to table) |
|
|
106
|
+
|
|
107
|
+
### Form view — edit mode
|
|
108
|
+
|
|
109
|
+
| Key | Action |
|
|
110
|
+
|-----|--------|
|
|
111
|
+
| `Enter` | Confirm and move to next field |
|
|
112
|
+
| `Esc` | Discard changes and exit edit |
|
|
113
|
+
| `Ctrl+E` | Open `$EDITOR` |
|
|
114
|
+
| `Left` / `Right` | Move cursor |
|
|
115
|
+
| `Home` / `End` | Jump to start/end |
|
|
116
|
+
| `Backspace` / `Delete` | Delete character |
|
|
108
117
|
|
|
109
118
|
## Architecture
|
|
110
119
|
|
data/lib/ratamin/app.rb
CHANGED
|
@@ -9,17 +9,20 @@ module Ratamin
|
|
|
9
9
|
@table_view = TableView.new(data_source)
|
|
10
10
|
@form_state = nil
|
|
11
11
|
@form_view = nil
|
|
12
|
+
@form_action = nil
|
|
12
13
|
@mode = :table
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def run
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
@data_source.with_silenced_output do
|
|
18
|
+
RatatuiRuby.run do |tui|
|
|
19
|
+
loop do
|
|
20
|
+
tui.draw { |frame| render(tui, frame) }
|
|
21
|
+
|
|
22
|
+
event = tui.poll_event
|
|
23
|
+
result = handle_event(event)
|
|
24
|
+
break if result == :quit
|
|
25
|
+
end
|
|
23
26
|
end
|
|
24
27
|
end
|
|
25
28
|
end
|
|
@@ -55,13 +58,20 @@ module Ratamin
|
|
|
55
58
|
end
|
|
56
59
|
|
|
57
60
|
def table_help
|
|
58
|
-
help = " q:quit j/↓:down k/↑:up g:first G:last
|
|
59
|
-
help += "
|
|
61
|
+
help = " Esc/q:quit j/↓:down k/↑:up g:first G:last l/Enter:edit n:new r:reload"
|
|
62
|
+
help += " PgDn:next PgUp:prev" if @data_source.paginated?
|
|
60
63
|
help + " "
|
|
61
64
|
end
|
|
62
65
|
|
|
63
66
|
def form_help
|
|
64
|
-
|
|
67
|
+
case @form_state&.mode
|
|
68
|
+
when :edit
|
|
69
|
+
" Esc:discard Enter:confirm Ctrl+E:editor "
|
|
70
|
+
when :confirm_exit
|
|
71
|
+
" ←/→:switch Enter:confirm Esc:back "
|
|
72
|
+
else
|
|
73
|
+
" s:save h/Esc:back j/k:navigate l/Enter:edit Ctrl+E:editor "
|
|
74
|
+
end
|
|
65
75
|
end
|
|
66
76
|
|
|
67
77
|
def handle_event(event)
|
|
@@ -73,7 +83,7 @@ module Ratamin
|
|
|
73
83
|
|
|
74
84
|
def handle_table_event(event)
|
|
75
85
|
case event
|
|
76
|
-
in {type: :key, code: "q"} | {type: :key, code: "c", modifiers: ["ctrl"]}
|
|
86
|
+
in {type: :key, code: "q"} | {type: :key, code: "esc"} | {type: :key, code: "c", modifiers: ["ctrl"]}
|
|
77
87
|
:quit
|
|
78
88
|
in {type: :key, code: "j"} | {type: :key, code: "down"}
|
|
79
89
|
@table_view.select_next
|
|
@@ -87,14 +97,17 @@ module Ratamin
|
|
|
87
97
|
in {type: :key, code: "G"} | {type: :key, code: "g", modifiers: ["shift"]}
|
|
88
98
|
@table_view.select_last
|
|
89
99
|
nil
|
|
90
|
-
in {type: :key, code: "enter"}
|
|
100
|
+
in {type: :key, code: "enter"} | {type: :key, code: "l"}
|
|
91
101
|
enter_form
|
|
92
102
|
nil
|
|
93
|
-
in {type: :key, code: "n"}
|
|
103
|
+
in {type: :key, code: "n"}
|
|
104
|
+
enter_new_form
|
|
105
|
+
nil
|
|
106
|
+
in {type: :key, code: "page_down"} if @data_source.paginated?
|
|
94
107
|
@data_source.next_page
|
|
95
108
|
@table_view.reset_selection
|
|
96
109
|
nil
|
|
97
|
-
in {type: :key, code: "
|
|
110
|
+
in {type: :key, code: "page_up"} if @data_source.paginated?
|
|
98
111
|
@data_source.prev_page
|
|
99
112
|
@table_view.reset_selection
|
|
100
113
|
nil
|
|
@@ -126,6 +139,15 @@ module Ratamin
|
|
|
126
139
|
|
|
127
140
|
@form_state = FormState.new(@data_source.columns, row)
|
|
128
141
|
@form_view = FormView.new(@form_state)
|
|
142
|
+
@form_action = :edit
|
|
143
|
+
@mode = :form
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def enter_new_form
|
|
147
|
+
empty_row = @data_source.columns.each_with_object({}) { |col, h| h[col.key] = "" }
|
|
148
|
+
@form_state = FormState.new(@data_source.columns, empty_row)
|
|
149
|
+
@form_view = FormView.new(@form_state, title: " New Record ")
|
|
150
|
+
@form_action = :create
|
|
129
151
|
@mode = :form
|
|
130
152
|
end
|
|
131
153
|
|
|
@@ -133,17 +155,27 @@ module Ratamin
|
|
|
133
155
|
@mode = :table
|
|
134
156
|
@form_state = nil
|
|
135
157
|
@form_view = nil
|
|
158
|
+
@form_action = nil
|
|
136
159
|
end
|
|
137
160
|
|
|
138
161
|
def save_form
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
result = if @form_action == :create
|
|
163
|
+
@data_source.create_row(@form_state.changes)
|
|
164
|
+
else
|
|
165
|
+
idx = @table_view.selected_index
|
|
166
|
+
return unless idx
|
|
167
|
+
@data_source.update_row(idx, @form_state.changes)
|
|
168
|
+
end
|
|
141
169
|
|
|
142
|
-
result = @data_source.update_row(idx, @form_state.changes)
|
|
143
170
|
if result.ok?
|
|
171
|
+
if @form_action == :create
|
|
172
|
+
@data_source.reload!
|
|
173
|
+
@table_view.reset_selection
|
|
174
|
+
end
|
|
144
175
|
exit_form
|
|
145
176
|
else
|
|
146
177
|
@form_state.set_errors(result.errors)
|
|
178
|
+
@form_state.reset_mode
|
|
147
179
|
end
|
|
148
180
|
end
|
|
149
181
|
|
data/lib/ratamin/column.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Ratamin
|
|
4
4
|
Column = Data.define(:key, :label, :width, :type, :editable) do
|
|
5
5
|
def initialize(key:, label: nil, width: nil, type: :string, editable: true)
|
|
6
|
-
super(key: key, label: label || key.to_s
|
|
6
|
+
super(key: key, label: label || key.to_s, width: width, type: type, editable: editable)
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
end
|
data/lib/ratamin/data_source.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Ratamin
|
|
|
7
7
|
# #rows -> [Hash] (current page of data)
|
|
8
8
|
# #row_count -> Integer
|
|
9
9
|
# #update_row(index, changes) -> UpdateResult
|
|
10
|
+
# #create_row(attributes) -> UpdateResult
|
|
10
11
|
# #reload! -> void
|
|
11
12
|
#
|
|
12
13
|
# Pagination (optional, when #paginated? returns true):
|
|
@@ -20,5 +21,9 @@ module Ratamin
|
|
|
20
21
|
def paginated?
|
|
21
22
|
false
|
|
22
23
|
end
|
|
24
|
+
|
|
25
|
+
def with_silenced_output
|
|
26
|
+
yield
|
|
27
|
+
end
|
|
23
28
|
end
|
|
24
29
|
end
|
data/lib/ratamin/form_state.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Ratamin
|
|
4
4
|
class FormState
|
|
5
|
-
attr_reader :columns, :values, :field_index, :cursor_positions, :errors
|
|
5
|
+
attr_reader :columns, :values, :field_index, :cursor_positions, :errors, :mode, :confirm_selection
|
|
6
6
|
|
|
7
7
|
def initialize(columns, row)
|
|
8
8
|
@columns = columns
|
|
@@ -11,6 +11,7 @@ module Ratamin
|
|
|
11
11
|
@field_index = first_editable_index || 0
|
|
12
12
|
@cursor_positions = @values.map(&:length)
|
|
13
13
|
@errors = []
|
|
14
|
+
@mode = :select
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def errors?
|
|
@@ -25,47 +26,19 @@ module Ratamin
|
|
|
25
26
|
@errors = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
in {type: :key, code: "enter"}
|
|
32
|
-
return :save
|
|
33
|
-
in {type: :key, code: "esc"}
|
|
34
|
-
return :cancel
|
|
35
|
-
else
|
|
36
|
-
nil
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
case event
|
|
40
|
-
in {type: :key, code: "tab", modifiers: []}
|
|
41
|
-
return next_field
|
|
42
|
-
in {type: :key, code: "backtab"} | {type: :key, code: "tab", modifiers: ["shift"]}
|
|
43
|
-
return prev_field
|
|
44
|
-
else
|
|
45
|
-
nil
|
|
46
|
-
end
|
|
29
|
+
def reset_mode
|
|
30
|
+
@mode = :select
|
|
31
|
+
end
|
|
47
32
|
|
|
48
|
-
|
|
33
|
+
def dirty?
|
|
34
|
+
changes.any?
|
|
35
|
+
end
|
|
49
36
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
in {type: :key, code: "delete"}
|
|
56
|
-
handle_delete
|
|
57
|
-
in {type: :key, code: "left"}
|
|
58
|
-
move_cursor_left
|
|
59
|
-
in {type: :key, code: "right"}
|
|
60
|
-
move_cursor_right
|
|
61
|
-
in {type: :key, code: "home"}
|
|
62
|
-
@cursor_positions[@field_index] = 0
|
|
63
|
-
in {type: :key, code: "end"}
|
|
64
|
-
@cursor_positions[@field_index] = @values[@field_index].length
|
|
65
|
-
in {type: :key, code: c, modifiers: []} if c.length == 1
|
|
66
|
-
insert_char(c)
|
|
67
|
-
else
|
|
68
|
-
nil
|
|
37
|
+
def handle_event(event)
|
|
38
|
+
case @mode
|
|
39
|
+
when :select then handle_select_event(event)
|
|
40
|
+
when :edit then handle_edit_event(event)
|
|
41
|
+
when :confirm_exit then handle_confirm_exit_event(event)
|
|
69
42
|
end
|
|
70
43
|
end
|
|
71
44
|
|
|
@@ -142,6 +115,92 @@ module Ratamin
|
|
|
142
115
|
|
|
143
116
|
private
|
|
144
117
|
|
|
118
|
+
def handle_select_event(event)
|
|
119
|
+
case event
|
|
120
|
+
in {type: :key, code: "esc"} | {type: :key, code: "h", modifiers: []}
|
|
121
|
+
if dirty?
|
|
122
|
+
@confirm_selection = 0
|
|
123
|
+
@mode = :confirm_exit
|
|
124
|
+
else
|
|
125
|
+
return :cancel
|
|
126
|
+
end
|
|
127
|
+
in {type: :key, code: "s", modifiers: []}
|
|
128
|
+
return :save
|
|
129
|
+
in {type: :key, code: "e", modifiers: ["ctrl"]}
|
|
130
|
+
return :editor if current_column&.editable
|
|
131
|
+
in {type: :key, code: "enter"} | {type: :key, code: "l", modifiers: []}
|
|
132
|
+
enter_edit_mode
|
|
133
|
+
in {type: :key, code: "j", modifiers: []} | {type: :key, code: "down"}
|
|
134
|
+
next_field
|
|
135
|
+
in {type: :key, code: "k", modifiers: []} | {type: :key, code: "up"}
|
|
136
|
+
prev_field
|
|
137
|
+
in {type: :key, code: "tab", modifiers: []}
|
|
138
|
+
next_field
|
|
139
|
+
in {type: :key, code: "backtab"} | {type: :key, code: "tab", modifiers: ["shift"]}
|
|
140
|
+
prev_field
|
|
141
|
+
else
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def handle_edit_event(event)
|
|
147
|
+
case event
|
|
148
|
+
in {type: :key, code: "esc"}
|
|
149
|
+
discard_edit
|
|
150
|
+
in {type: :key, code: "enter"}
|
|
151
|
+
@mode = :select
|
|
152
|
+
in {type: :key, code: "e", modifiers: ["ctrl"]}
|
|
153
|
+
return :editor
|
|
154
|
+
in {type: :key, code: "backspace"}
|
|
155
|
+
handle_backspace
|
|
156
|
+
in {type: :key, code: "delete"}
|
|
157
|
+
handle_delete
|
|
158
|
+
in {type: :key, code: "left"}
|
|
159
|
+
move_cursor_left
|
|
160
|
+
in {type: :key, code: "right"}
|
|
161
|
+
move_cursor_right
|
|
162
|
+
in {type: :key, code: "home"}
|
|
163
|
+
@cursor_positions[@field_index] = 0
|
|
164
|
+
in {type: :key, code: "end"}
|
|
165
|
+
@cursor_positions[@field_index] = @values[@field_index].length
|
|
166
|
+
in {type: :key, code: c, modifiers: []} if c.length == 1
|
|
167
|
+
insert_char(c)
|
|
168
|
+
else
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def handle_confirm_exit_event(event)
|
|
174
|
+
case event
|
|
175
|
+
in {type: :key, code: "enter"}
|
|
176
|
+
@confirm_selection == 0 ? :save : :cancel
|
|
177
|
+
in {type: :key, code: "h", modifiers: []} | {type: :key, code: "left"} |
|
|
178
|
+
{type: :key, code: "l", modifiers: []} | {type: :key, code: "right"} |
|
|
179
|
+
{type: :key, code: "tab"}
|
|
180
|
+
@confirm_selection = 1 - @confirm_selection
|
|
181
|
+
nil
|
|
182
|
+
in {type: :key, code: "esc"}
|
|
183
|
+
@mode = :select
|
|
184
|
+
nil
|
|
185
|
+
else
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def enter_edit_mode
|
|
191
|
+
return unless current_column&.editable
|
|
192
|
+
@edit_snapshot = [@values[@field_index].dup, @cursor_positions[@field_index]]
|
|
193
|
+
@mode = :edit
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def discard_edit
|
|
197
|
+
if @edit_snapshot
|
|
198
|
+
@values[@field_index], @cursor_positions[@field_index] = @edit_snapshot
|
|
199
|
+
@edit_snapshot = nil
|
|
200
|
+
end
|
|
201
|
+
@mode = :select
|
|
202
|
+
end
|
|
203
|
+
|
|
145
204
|
def any_editable?
|
|
146
205
|
columns.any?(&:editable)
|
|
147
206
|
end
|
data/lib/ratamin/form_view.rb
CHANGED
|
@@ -2,16 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
module Ratamin
|
|
4
4
|
class FormView
|
|
5
|
-
attr_reader :state
|
|
5
|
+
attr_reader :state, :title
|
|
6
6
|
|
|
7
|
-
def initialize(state)
|
|
7
|
+
def initialize(state, title: " Edit Record ")
|
|
8
8
|
@state = state
|
|
9
|
+
@title = title
|
|
9
10
|
end
|
|
10
11
|
|
|
11
12
|
def render(tui, frame, area)
|
|
12
13
|
border_style = state.errors? ? {fg: :red} : {fg: :yellow}
|
|
13
14
|
inner_block = tui.block(
|
|
14
|
-
title:
|
|
15
|
+
title: title,
|
|
15
16
|
borders: [:all],
|
|
16
17
|
border_type: :rounded,
|
|
17
18
|
border_style: border_style
|
|
@@ -22,14 +23,8 @@ module Ratamin
|
|
|
22
23
|
columns = state.columns
|
|
23
24
|
|
|
24
25
|
constraints = []
|
|
25
|
-
|
|
26
|
-
if state.errors?
|
|
27
|
-
constraints << RatatuiRuby::Layout::Constraint.length(state.errors.length + 1)
|
|
28
|
-
end
|
|
29
|
-
# Field rows
|
|
26
|
+
constraints << RatatuiRuby::Layout::Constraint.length(state.errors.length + 1) if state.errors?
|
|
30
27
|
columns.each { constraints << RatatuiRuby::Layout::Constraint.length(1) }
|
|
31
|
-
# Help line + spacer
|
|
32
|
-
constraints << RatatuiRuby::Layout::Constraint.length(1)
|
|
33
28
|
constraints << RatatuiRuby::Layout::Constraint.fill(1)
|
|
34
29
|
|
|
35
30
|
regions = RatatuiRuby::Layout::Layout.split(
|
|
@@ -40,11 +35,9 @@ module Ratamin
|
|
|
40
35
|
|
|
41
36
|
region_idx = 0
|
|
42
37
|
|
|
43
|
-
# Render errors if present
|
|
44
38
|
if state.errors?
|
|
45
39
|
error_text = state.errors.map { |e| " ! #{e}" }.join("\n")
|
|
46
|
-
|
|
47
|
-
frame.render_widget(error_widget, regions[region_idx])
|
|
40
|
+
frame.render_widget(tui.paragraph(text: error_text, style: {fg: :red, modifiers: [:bold]}), regions[region_idx])
|
|
48
41
|
region_idx += 1
|
|
49
42
|
end
|
|
50
43
|
|
|
@@ -52,10 +45,11 @@ module Ratamin
|
|
|
52
45
|
|
|
53
46
|
columns.each_with_index do |col, i|
|
|
54
47
|
field_area = regions[region_idx + i]
|
|
55
|
-
label_area, value_area = RatatuiRuby::Layout::Layout.split(
|
|
48
|
+
marker_area, label_area, value_area = RatatuiRuby::Layout::Layout.split(
|
|
56
49
|
field_area,
|
|
57
50
|
direction: :horizontal,
|
|
58
51
|
constraints: [
|
|
52
|
+
RatatuiRuby::Layout::Constraint.length(2),
|
|
59
53
|
RatatuiRuby::Layout::Constraint.length(label_width),
|
|
60
54
|
RatatuiRuby::Layout::Constraint.fill(1)
|
|
61
55
|
]
|
|
@@ -64,34 +58,93 @@ module Ratamin
|
|
|
64
58
|
active = i == state.field_index
|
|
65
59
|
|
|
66
60
|
if col.editable
|
|
61
|
+
in_edit = active && state.mode == :edit
|
|
62
|
+
marker = active ? (in_edit ? "» " : "> ") : " "
|
|
63
|
+
marker_style = active ? {fg: :yellow} : {fg: :dark_gray}
|
|
67
64
|
label_style = active ? {fg: :yellow, modifiers: [:bold]} : {fg: :dark_gray}
|
|
65
|
+
|
|
66
|
+
frame.render_widget(tui.paragraph(text: marker, style: marker_style), marker_area)
|
|
68
67
|
frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: label_style), label_area)
|
|
69
68
|
|
|
70
69
|
display_value = if col.type == :text && state.values[i].length > 40
|
|
71
|
-
"[
|
|
70
|
+
"[Ctrl+E to edit in $EDITOR]"
|
|
72
71
|
else
|
|
73
72
|
state.values[i]
|
|
74
73
|
end
|
|
75
74
|
|
|
76
|
-
value_style =
|
|
75
|
+
value_style = in_edit ? {fg: :white, modifiers: [:underlined]} : (active ? {fg: :yellow} : {fg: :gray})
|
|
77
76
|
frame.render_widget(tui.paragraph(text: display_value, style: value_style), value_area)
|
|
78
77
|
|
|
79
|
-
if
|
|
78
|
+
if in_edit && !(col.type == :text && state.values[i].length > 40)
|
|
80
79
|
cursor_x = value_area.x + [state.cursor_positions[i], value_area.width - 1].min
|
|
81
80
|
frame.set_cursor_position(cursor_x, value_area.y)
|
|
82
81
|
end
|
|
83
82
|
else
|
|
83
|
+
frame.render_widget(tui.paragraph(text: " ", style: {fg: :dark_gray}), marker_area)
|
|
84
84
|
frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: {fg: :dark_gray}), label_area)
|
|
85
85
|
frame.render_widget(tui.paragraph(text: state.values[i], style: {fg: :dark_gray, modifiers: [:dim]}), value_area)
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
render_confirm_dialog(tui, frame, area) if state.mode == :confirm_exit
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def render_confirm_dialog(tui, frame, area)
|
|
95
|
+
# Clear overlay area
|
|
96
|
+
dialog_w = [36, area.width - 4].min
|
|
97
|
+
dialog_h = 5
|
|
98
|
+
dialog_x = area.x + (area.width - dialog_w) / 2
|
|
99
|
+
dialog_y = area.y + (area.height - dialog_h) / 2
|
|
100
|
+
|
|
101
|
+
dialog_area = RatatuiRuby::Layout::Rect.new(x: dialog_x, y: dialog_y, width: dialog_w, height: dialog_h)
|
|
102
|
+
frame.render_widget(tui.clear, dialog_area)
|
|
103
|
+
|
|
104
|
+
dialog_block = tui.block(
|
|
105
|
+
title: " Unsaved changes ",
|
|
106
|
+
borders: [:all],
|
|
107
|
+
border_type: :rounded,
|
|
108
|
+
border_style: {fg: :yellow}
|
|
109
|
+
)
|
|
110
|
+
inner = dialog_block.inner(dialog_area)
|
|
111
|
+
frame.render_widget(dialog_block, dialog_area)
|
|
112
|
+
|
|
113
|
+
# Prompt line
|
|
114
|
+
prompt_area, buttons_area = RatatuiRuby::Layout::Layout.split(
|
|
115
|
+
inner,
|
|
116
|
+
direction: :vertical,
|
|
117
|
+
constraints: [
|
|
118
|
+
RatatuiRuby::Layout::Constraint.length(1),
|
|
119
|
+
RatatuiRuby::Layout::Constraint.fill(1)
|
|
120
|
+
]
|
|
93
121
|
)
|
|
94
|
-
|
|
122
|
+
|
|
123
|
+
frame.render_widget(
|
|
124
|
+
tui.paragraph(text: "Save changes before leaving?", style: {fg: :white}),
|
|
125
|
+
prompt_area
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Buttons
|
|
129
|
+
sel = state.confirm_selection
|
|
130
|
+
save_style = sel == 0 ? {fg: :black, bg: :yellow, modifiers: [:bold]} : {fg: :dark_gray}
|
|
131
|
+
discard_style = sel == 1 ? {fg: :black, bg: :yellow, modifiers: [:bold]} : {fg: :dark_gray}
|
|
132
|
+
|
|
133
|
+
save_label = sel == 0 ? " [Save] " : " Save "
|
|
134
|
+
discard_label = sel == 1 ? " [Discard] " : " Discard "
|
|
135
|
+
|
|
136
|
+
btn_save, btn_discard, _ = RatatuiRuby::Layout::Layout.split(
|
|
137
|
+
buttons_area,
|
|
138
|
+
direction: :horizontal,
|
|
139
|
+
constraints: [
|
|
140
|
+
RatatuiRuby::Layout::Constraint.length(save_label.length),
|
|
141
|
+
RatatuiRuby::Layout::Constraint.length(discard_label.length),
|
|
142
|
+
RatatuiRuby::Layout::Constraint.fill(1)
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
frame.render_widget(tui.paragraph(text: save_label, style: save_style), btn_save)
|
|
147
|
+
frame.render_widget(tui.paragraph(text: discard_label, style: discard_style), btn_discard)
|
|
95
148
|
end
|
|
96
149
|
end
|
|
97
150
|
end
|
data/lib/ratamin/railtie.rb
CHANGED
|
@@ -50,6 +50,20 @@ module Ratamin
|
|
|
50
50
|
@records.length
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def create_row(attributes)
|
|
54
|
+
record = @scope.klass.new
|
|
55
|
+
cast_attrs = attributes.transform_keys(&:to_s)
|
|
56
|
+
record.assign_attributes(cast_attrs)
|
|
57
|
+
|
|
58
|
+
if record.save
|
|
59
|
+
UpdateResult.success
|
|
60
|
+
else
|
|
61
|
+
UpdateResult.failure(record.errors.full_messages)
|
|
62
|
+
end
|
|
63
|
+
rescue => e
|
|
64
|
+
UpdateResult.failure("#{e.class}: #{e.message}")
|
|
65
|
+
end
|
|
66
|
+
|
|
53
67
|
def update_row(index, changes)
|
|
54
68
|
record = @records[index]
|
|
55
69
|
cast_changes = changes.transform_keys(&:to_s)
|
|
@@ -58,8 +72,21 @@ module Ratamin
|
|
|
58
72
|
if record.save
|
|
59
73
|
UpdateResult.success
|
|
60
74
|
else
|
|
61
|
-
|
|
75
|
+
errors = record.errors.full_messages
|
|
76
|
+
record.reload
|
|
77
|
+
UpdateResult.failure(errors)
|
|
62
78
|
end
|
|
79
|
+
rescue => e
|
|
80
|
+
record&.reload rescue nil
|
|
81
|
+
UpdateResult.failure("#{e.class}: #{e.message}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def with_silenced_output
|
|
85
|
+
old_logger = ActiveRecord::Base.logger
|
|
86
|
+
ActiveRecord::Base.logger = nil
|
|
87
|
+
yield
|
|
88
|
+
ensure
|
|
89
|
+
ActiveRecord::Base.logger = old_logger
|
|
63
90
|
end
|
|
64
91
|
|
|
65
92
|
def reload!
|
data/lib/ratamin/table_view.rb
CHANGED
|
@@ -97,10 +97,6 @@ module Ratamin
|
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
def compute_widths(columns, available_width)
|
|
100
|
-
# Reserve space for borders (2) + highlight symbol (2) + column spacing
|
|
101
|
-
usable = available_width - 4
|
|
102
|
-
total_spacing = [columns.length - 1, 0].max
|
|
103
|
-
|
|
104
100
|
columns.map do |col|
|
|
105
101
|
if col.width
|
|
106
102
|
RatatuiRuby::Layout::Constraint.length(col.width)
|
data/lib/ratamin/version.rb
CHANGED
data/lib/ratamin.rb
CHANGED