ratamin 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 657e9468e290eff1deeeb657db061a98dca992bece11887421737f1094bbfa28
4
+ data.tar.gz: 2d0672f141d7cd9596ccd108e848a0fbd7418f77de46b3f67e6a2aa6a1efe7b9
5
+ SHA512:
6
+ metadata.gz: 93d38f02ff57591d942ed0f463ed5098a46e084a1270b0aadbdf6b1f3496d23e7a4ec5a30c95f6c6fec23baa92dbe63ee82a5763991d4b4be7ae8cbf4d7dba98
7
+ data.tar.gz: 15cf1c96fd1193292b98ef73626ea02859ee34c5d6f565c612a93a3d3adddaa62e1b3796185dadb2caa185e7fff0eddd4c43eade57a3e49560163a42fa813acb
@@ -0,0 +1,21 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ ruby: ["3.3", "3.4", "4.0"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: ruby/setup-ruby@v1
18
+ with:
19
+ ruby-version: ${{ matrix.ruby }}
20
+ bundler-cache: true
21
+ - run: bundle exec rspec
data/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "references/ratatui_ruby"]
2
+ path = references/ratatui_ruby
3
+ url = https://git.sr.ht/~kerrick/ratatui_ruby
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-14
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 merely
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # Ratamin
2
+
3
+ A TUI admin console for ActiveRecord, built on [ratatui_ruby](https://github.com/nicholasgasior/ratatui_ruby).
4
+
5
+ Give it a scope, get a navigable table with inline editing and `$EDITOR` support for long text.
6
+
7
+ ## Installation
8
+
9
+ Add to your Gemfile:
10
+
11
+ ```ruby
12
+ gem "ratamin"
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### With ActiveRecord
18
+
19
+ ```ruby
20
+ require "ratamin"
21
+
22
+ # Edit all users
23
+ Ratamin::App.new(Ratamin::ScopeDataSource.new(User.all)).run
24
+
25
+ # Edit a filtered scope
26
+ Ratamin::App.new(Ratamin::ScopeDataSource.new(User.where(role: "admin"))).run
27
+
28
+ # Specify which columns to show
29
+ columns = [
30
+ Ratamin::Column.new(key: :name, label: "Name", width: 20),
31
+ Ratamin::Column.new(key: :email, label: "Email", width: 30),
32
+ Ratamin::Column.new(key: :bio, label: "Bio", type: :text),
33
+ ]
34
+ Ratamin::App.new(Ratamin::ScopeDataSource.new(User.all, columns: columns)).run
35
+ ```
36
+
37
+ ### Without ActiveRecord
38
+
39
+ ```ruby
40
+ require "ratamin"
41
+
42
+ columns = [
43
+ Ratamin::Column.new(key: :name, width: 20),
44
+ Ratamin::Column.new(key: :email, width: 30),
45
+ ]
46
+
47
+ rows = [
48
+ { name: "Alice", email: "alice@example.com" },
49
+ { name: "Bob", email: "bob@example.com" },
50
+ ]
51
+
52
+ ds = Ratamin::ArrayDataSource.new(columns: columns, rows: rows)
53
+ Ratamin::App.new(ds).run
54
+ ```
55
+
56
+ ## Keybindings
57
+
58
+ ### Table view
59
+
60
+ | Key | Action |
61
+ |-----|--------|
62
+ | `j` / `Down` | Next row |
63
+ | `k` / `Up` | Previous row |
64
+ | `g` | First row |
65
+ | `G` | Last row |
66
+ | `Enter` | Edit selected record |
67
+ | `r` | Reload data |
68
+ | `q` | Quit |
69
+
70
+ ### Form view
71
+
72
+ | Key | Action |
73
+ |-----|--------|
74
+ | `Tab` | Next field |
75
+ | `Shift+Tab` | Previous field |
76
+ | `Enter` | Save |
77
+ | `Esc` | Cancel |
78
+ | `e` | Open `$EDITOR` (on `:text` fields) |
79
+
80
+ ## Architecture
81
+
82
+ Ratamin separates data, state, and rendering:
83
+
84
+ - **DataSource** — duck-type protocol (`#columns`, `#rows`, `#update_row`, `#reload!`)
85
+ - `ArrayDataSource` — in-memory arrays
86
+ - `ScopeDataSource` — wraps an ActiveRecord scope
87
+ - **State** — pure Ruby, no TUI dependency, fully unit-testable
88
+ - `FormState` — field values, cursor, text editing, validation errors
89
+ - **Views** — thin renderers that read from state objects
90
+ - `TableView` — record list with selection
91
+ - `FormView` — field-by-field editor
92
+
93
+ ## Column types
94
+
95
+ | Type | Behavior |
96
+ |------|----------|
97
+ | `:string` (default) | Inline text editing |
98
+ | `:text` | Truncated in table, opens `$EDITOR` for editing |
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ bin/setup
104
+ bundle exec rspec
105
+ ```
106
+
107
+ ## License
108
+
109
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/examples/demo.rb ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ratamin"
5
+
6
+ columns = [
7
+ Ratamin::Column.new(key: :id, label: "ID", width: 6),
8
+ Ratamin::Column.new(key: :name, label: "Name", width: 20),
9
+ Ratamin::Column.new(key: :email, label: "Email", width: 30),
10
+ Ratamin::Column.new(key: :role, label: "Role", width: 10),
11
+ Ratamin::Column.new(key: :bio, label: "Bio", type: :text)
12
+ ]
13
+
14
+ rows = [
15
+ {id: 1, name: "Alice", email: "alice@example.com", role: "admin", bio: "Software engineer with 10 years of experience in distributed systems and cloud infrastructure."},
16
+ {id: 2, name: "Bob", email: "bob@example.com", role: "user", bio: "Designer"},
17
+ {id: 3, name: "Charlie", email: "charlie@example.com", role: "user", bio: "Product manager focused on developer tools and platform engineering."},
18
+ {id: 4, name: "Diana", email: "diana@example.com", role: "moderator", bio: "Community lead"},
19
+ {id: 5, name: "Eve", email: "eve@example.com", role: "admin", bio: "Security researcher specializing in cryptographic protocols and zero-knowledge proofs."},
20
+ {id: 6, name: "Frank", email: "frank@example.com", role: "user", bio: "Full-stack developer"},
21
+ {id: 7, name: "Grace", email: "grace@example.com", role: "user", bio: "Data scientist working on ML pipelines and recommendation systems."},
22
+ {id: 8, name: "Hank", email: "hank@example.com", role: "moderator", bio: "DevOps engineer"},
23
+ {id: 9, name: "Ivy", email: "ivy@example.com", role: "user", bio: "Frontend developer passionate about accessibility and design systems."},
24
+ {id: 10, name: "Jack", email: "jack@example.com", role: "user", bio: "Backend engineer focused on high-performance APIs and database optimization."},
25
+ ]
26
+
27
+ data_source = Ratamin::ArrayDataSource.new(columns: columns, rows: rows)
28
+ app = Ratamin::App.new(data_source)
29
+ app.run
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class App
5
+ attr_reader :data_source, :mode, :table_view, :form_state, :form_view
6
+
7
+ def initialize(data_source)
8
+ @data_source = data_source
9
+ @table_view = TableView.new(data_source)
10
+ @form_state = nil
11
+ @form_view = nil
12
+ @mode = :table
13
+ end
14
+
15
+ def run
16
+ RatatuiRuby.run do |tui|
17
+ loop do
18
+ tui.draw { |frame| render(tui, frame) }
19
+
20
+ event = tui.poll_event
21
+ result = handle_event(event)
22
+ break if result == :quit
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def render(tui, frame)
30
+ body_area, status_area = RatatuiRuby::Layout::Layout.split(
31
+ frame.area,
32
+ direction: :vertical,
33
+ constraints: [
34
+ RatatuiRuby::Layout::Constraint.fill(1),
35
+ RatatuiRuby::Layout::Constraint.length(1)
36
+ ]
37
+ )
38
+
39
+ case @mode
40
+ when :table
41
+ @table_view.render(tui, frame, body_area)
42
+ render_status_bar(tui, frame, status_area, table_help)
43
+ when :form
44
+ @form_view.render(tui, frame, body_area)
45
+ render_status_bar(tui, frame, status_area, form_help)
46
+ end
47
+ end
48
+
49
+ def render_status_bar(tui, frame, area, text)
50
+ bar = tui.paragraph(
51
+ text: text,
52
+ style: {fg: :black, bg: :cyan}
53
+ )
54
+ frame.render_widget(bar, area)
55
+ end
56
+
57
+ def table_help
58
+ help = " q:quit j/↓:down k/↑:up g:first G:last enter:edit r:reload"
59
+ help += " n:next page p:prev page" if @data_source.paginated?
60
+ help + " "
61
+ end
62
+
63
+ def form_help
64
+ " Tab:next field Shift+Tab:prev Enter:save Esc:cancel "
65
+ end
66
+
67
+ def handle_event(event)
68
+ case @mode
69
+ when :table then handle_table_event(event)
70
+ when :form then handle_form_event(event)
71
+ end
72
+ end
73
+
74
+ def handle_table_event(event)
75
+ case event
76
+ in {type: :key, code: "q"} | {type: :key, code: "c", modifiers: ["ctrl"]}
77
+ :quit
78
+ in {type: :key, code: "j"} | {type: :key, code: "down"}
79
+ @table_view.select_next
80
+ nil
81
+ in {type: :key, code: "k"} | {type: :key, code: "up"}
82
+ @table_view.select_previous
83
+ nil
84
+ in {type: :key, code: "g"}
85
+ @table_view.select_first
86
+ nil
87
+ in {type: :key, code: "G"} | {type: :key, code: "g", modifiers: ["shift"]}
88
+ @table_view.select_last
89
+ nil
90
+ in {type: :key, code: "enter"}
91
+ enter_form
92
+ nil
93
+ in {type: :key, code: "n"} if @data_source.paginated?
94
+ @data_source.next_page
95
+ @table_view.reset_selection
96
+ nil
97
+ in {type: :key, code: "p"} if @data_source.paginated?
98
+ @data_source.prev_page
99
+ @table_view.reset_selection
100
+ nil
101
+ in {type: :key, code: "r"}
102
+ @data_source.reload!
103
+ @table_view.reset_selection
104
+ nil
105
+ else
106
+ nil
107
+ end
108
+ end
109
+
110
+ def handle_form_event(event)
111
+ result = @form_state.handle_event(event)
112
+ case result
113
+ when :save
114
+ save_form
115
+ when :cancel
116
+ exit_form
117
+ when :editor
118
+ open_editor
119
+ end
120
+ nil
121
+ end
122
+
123
+ def enter_form
124
+ row = @table_view.selected_row
125
+ return unless row
126
+
127
+ @form_state = FormState.new(@data_source.columns, row)
128
+ @form_view = FormView.new(@form_state)
129
+ @mode = :form
130
+ end
131
+
132
+ def exit_form
133
+ @mode = :table
134
+ @form_state = nil
135
+ @form_view = nil
136
+ end
137
+
138
+ def save_form
139
+ idx = @table_view.selected_index
140
+ return unless idx
141
+
142
+ result = @data_source.update_row(idx, @form_state.changes)
143
+ if result.ok?
144
+ exit_form
145
+ else
146
+ @form_state.set_errors(result.errors)
147
+ end
148
+ end
149
+
150
+ def open_editor
151
+ field_idx = @form_state.field_index
152
+ current_value = @form_state.values[field_idx]
153
+
154
+ edited = EditorBridge.edit(current_value)
155
+ @form_state.set_value(field_idx, edited.chomp)
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class ArrayDataSource
5
+ include DataSource
6
+
7
+ attr_reader :columns, :rows
8
+
9
+ # @param columns [Array<Column>] column definitions
10
+ # @param rows [Array<Hash>] row data, each hash maps column key -> value
11
+ def initialize(columns:, rows:)
12
+ @columns = columns
13
+ @rows = rows.map(&:dup)
14
+ end
15
+
16
+ def row_count
17
+ @rows.length
18
+ end
19
+
20
+ def update_row(index, changes)
21
+ changes.each { |k, v| @rows[index][k] = v }
22
+ UpdateResult.success
23
+ end
24
+
25
+ def reload!
26
+ # no-op for array source
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ Column = Data.define(:key, :label, :width, :type, :editable) do
5
+ def initialize(key:, label: nil, width: nil, type: :string, editable: true)
6
+ super(key: key, label: label || key.to_s.capitalize, width: width, type: type, editable: editable)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ # Duck-type protocol for data sources.
5
+ # Implementations must respond to:
6
+ # #columns -> [Column]
7
+ # #rows -> [Hash] (current page of data)
8
+ # #row_count -> Integer
9
+ # #update_row(index, changes) -> UpdateResult
10
+ # #reload! -> void
11
+ #
12
+ # Pagination (optional, when #paginated? returns true):
13
+ # #page -> Integer (1-indexed)
14
+ # #total_count -> Integer
15
+ # #total_pages -> Integer
16
+ # #next_page -> void
17
+ # #prev_page -> void
18
+ # #go_to_page(n) -> void
19
+ module DataSource
20
+ def paginated?
21
+ false
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "shellwords"
5
+
6
+ module Ratamin
7
+ module EditorBridge
8
+ # Opens $EDITOR with the given text, returns edited text.
9
+ # Temporarily restores the terminal so the editor can run normally.
10
+ def self.edit(text)
11
+ editor = ENV["EDITOR"] || ENV["VISUAL"] || "vi"
12
+
13
+ tmpfile = Tempfile.new(["ratamin_edit", ".txt"])
14
+ tmpfile.write(text)
15
+ tmpfile.flush
16
+
17
+ RatatuiRuby.restore_terminal
18
+
19
+ # Use shell expansion so editors with arguments (e.g. "code --wait") work
20
+ success = system(*Shellwords.split(editor), tmpfile.path)
21
+
22
+ RatatuiRuby.init_terminal
23
+
24
+ unless success
25
+ tmpfile.close
26
+ tmpfile.unlink
27
+ return text
28
+ end
29
+
30
+ tmpfile.rewind
31
+ result = tmpfile.read
32
+ tmpfile.close
33
+ tmpfile.unlink
34
+
35
+ result
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class FormState
5
+ attr_reader :columns, :values, :field_index, :cursor_positions, :errors
6
+
7
+ def initialize(columns, row)
8
+ @columns = columns
9
+ @values = columns.map { |col| row[col.key].to_s }
10
+ @original_values = @values.dup
11
+ @field_index = first_editable_index || 0
12
+ @cursor_positions = @values.map(&:length)
13
+ @errors = []
14
+ end
15
+
16
+ def errors?
17
+ !@errors.empty?
18
+ end
19
+
20
+ def set_errors(errors)
21
+ @errors = Array(errors)
22
+ end
23
+
24
+ def clear_errors
25
+ @errors = []
26
+ end
27
+
28
+ def handle_event(event)
29
+ # Enter/Esc always work, even when all fields are read-only.
30
+ case event
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
47
+
48
+ return nil unless current_column&.editable
49
+
50
+ case event
51
+ in {type: :key, code: "e"} if current_column.type == :text
52
+ return :editor
53
+ in {type: :key, code: "backspace"}
54
+ handle_backspace
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
69
+ end
70
+ end
71
+
72
+ def current_column
73
+ columns[@field_index]
74
+ end
75
+
76
+ def current_value
77
+ @values[@field_index]
78
+ end
79
+
80
+ def set_value(index, value)
81
+ @values[index] = value.to_s
82
+ @cursor_positions[index] = @values[index].length
83
+ end
84
+
85
+ # Returns only dirty editable fields.
86
+ def changes
87
+ result = {}
88
+ columns.each_with_index do |col, i|
89
+ next unless col.editable
90
+ next if @values[i] == @original_values[i]
91
+ result[col.key] = @values[i]
92
+ end
93
+ result
94
+ end
95
+
96
+ def next_field
97
+ return unless any_editable?
98
+ loop do
99
+ @field_index = (@field_index + 1) % columns.length
100
+ break if current_column.editable
101
+ end
102
+ end
103
+
104
+ def prev_field
105
+ return unless any_editable?
106
+ loop do
107
+ @field_index = (@field_index - 1) % columns.length
108
+ break if current_column.editable
109
+ end
110
+ end
111
+
112
+ def insert_char(c)
113
+ pos = @cursor_positions[@field_index]
114
+ @values[@field_index] = @values[@field_index].dup.insert(pos, c)
115
+ @cursor_positions[@field_index] += 1
116
+ end
117
+
118
+ def handle_backspace
119
+ pos = @cursor_positions[@field_index]
120
+ return if pos == 0
121
+ val = @values[@field_index].dup
122
+ val.slice!(pos - 1)
123
+ @values[@field_index] = val
124
+ @cursor_positions[@field_index] -= 1
125
+ end
126
+
127
+ def handle_delete
128
+ pos = @cursor_positions[@field_index]
129
+ val = @values[@field_index].dup
130
+ return if pos >= val.length
131
+ val.slice!(pos)
132
+ @values[@field_index] = val
133
+ end
134
+
135
+ def move_cursor_left
136
+ @cursor_positions[@field_index] = [0, @cursor_positions[@field_index] - 1].max
137
+ end
138
+
139
+ def move_cursor_right
140
+ @cursor_positions[@field_index] = [@values[@field_index].length, @cursor_positions[@field_index] + 1].min
141
+ end
142
+
143
+ private
144
+
145
+ def any_editable?
146
+ columns.any?(&:editable)
147
+ end
148
+
149
+ def first_editable_index
150
+ columns.index(&:editable)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class FormView
5
+ attr_reader :state
6
+
7
+ def initialize(state)
8
+ @state = state
9
+ end
10
+
11
+ def render(tui, frame, area)
12
+ border_style = state.errors? ? {fg: :red} : {fg: :yellow}
13
+ inner_block = tui.block(
14
+ title: " Edit Record ",
15
+ borders: [:all],
16
+ border_type: :rounded,
17
+ border_style: border_style
18
+ )
19
+ inner_area = inner_block.inner(area)
20
+ frame.render_widget(inner_block, area)
21
+
22
+ columns = state.columns
23
+
24
+ constraints = []
25
+ # Error lines
26
+ if state.errors?
27
+ constraints << RatatuiRuby::Layout::Constraint.length(state.errors.length + 1)
28
+ end
29
+ # Field rows
30
+ columns.each { constraints << RatatuiRuby::Layout::Constraint.length(1) }
31
+ # Help line + spacer
32
+ constraints << RatatuiRuby::Layout::Constraint.length(1)
33
+ constraints << RatatuiRuby::Layout::Constraint.fill(1)
34
+
35
+ regions = RatatuiRuby::Layout::Layout.split(
36
+ inner_area,
37
+ direction: :vertical,
38
+ constraints: constraints
39
+ )
40
+
41
+ region_idx = 0
42
+
43
+ # Render errors if present
44
+ if state.errors?
45
+ error_text = state.errors.map { |e| " ! #{e}" }.join("\n")
46
+ error_widget = tui.paragraph(text: error_text, style: {fg: :red, modifiers: [:bold]})
47
+ frame.render_widget(error_widget, regions[region_idx])
48
+ region_idx += 1
49
+ end
50
+
51
+ label_width = columns.map { |c| c.label.length }.max + 2
52
+
53
+ columns.each_with_index do |col, i|
54
+ field_area = regions[region_idx + i]
55
+ label_area, value_area = RatatuiRuby::Layout::Layout.split(
56
+ field_area,
57
+ direction: :horizontal,
58
+ constraints: [
59
+ RatatuiRuby::Layout::Constraint.length(label_width),
60
+ RatatuiRuby::Layout::Constraint.fill(1)
61
+ ]
62
+ )
63
+
64
+ active = i == state.field_index
65
+
66
+ if col.editable
67
+ label_style = active ? {fg: :yellow, modifiers: [:bold]} : {fg: :dark_gray}
68
+ frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: label_style), label_area)
69
+
70
+ display_value = if col.type == :text && state.values[i].length > 40
71
+ "[press 'e' to edit in $EDITOR]"
72
+ else
73
+ state.values[i]
74
+ end
75
+
76
+ value_style = active ? {fg: :white, modifiers: [:underlined]} : {fg: :gray}
77
+ frame.render_widget(tui.paragraph(text: display_value, style: value_style), value_area)
78
+
79
+ if active && col.type != :text
80
+ cursor_x = value_area.x + [state.cursor_positions[i], value_area.width - 1].min
81
+ frame.set_cursor_position(cursor_x, value_area.y)
82
+ end
83
+ else
84
+ frame.render_widget(tui.paragraph(text: "#{col.label}: ", style: {fg: :dark_gray}), label_area)
85
+ frame.render_widget(tui.paragraph(text: state.values[i], style: {fg: :dark_gray, modifiers: [:dim]}), value_area)
86
+ end
87
+ end
88
+
89
+ help_area = regions[region_idx + columns.length]
90
+ help = tui.paragraph(
91
+ text: " Tab:next Shift+Tab:prev Enter:save Esc:cancel e:editor(text) ",
92
+ style: {fg: :dark_gray}
93
+ )
94
+ frame.render_widget(help, help_area)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class ScopeDataSource
5
+ include DataSource
6
+
7
+ attr_reader :columns, :page, :per_page, :total_count
8
+
9
+ # @param scope [ActiveRecord::Relation] the AR scope to display
10
+ # @param columns [Array<Column>, nil] explicit columns, or nil to infer from model
11
+ # @param per_page [Integer] records per page
12
+ def initialize(scope, columns: nil, per_page: 20)
13
+ @scope = scope
14
+ @columns = columns || infer_columns
15
+ @per_page = per_page
16
+ @page = 1
17
+ @total_count = 0
18
+ @records = []
19
+ reload!
20
+ end
21
+
22
+ def paginated?
23
+ true
24
+ end
25
+
26
+ def total_pages
27
+ [(@total_count.to_f / @per_page).ceil, 1].max
28
+ end
29
+
30
+ def next_page
31
+ go_to_page(@page + 1)
32
+ end
33
+
34
+ def prev_page
35
+ go_to_page(@page - 1)
36
+ end
37
+
38
+ def go_to_page(n)
39
+ n = [[1, n].max, total_pages].min
40
+ return if n == @page
41
+ @page = n
42
+ load_page
43
+ end
44
+
45
+ def rows
46
+ @records.map { |record| row_hash(record) }
47
+ end
48
+
49
+ def row_count
50
+ @records.length
51
+ end
52
+
53
+ def update_row(index, changes)
54
+ record = @records[index]
55
+ cast_changes = changes.transform_keys(&:to_s)
56
+ record.assign_attributes(cast_changes)
57
+
58
+ if record.save
59
+ UpdateResult.success
60
+ else
61
+ UpdateResult.failure(record.errors.full_messages)
62
+ end
63
+ end
64
+
65
+ def reload!
66
+ @total_count = @scope.reset.count
67
+ @page = [[1, @page].max, total_pages].min
68
+ load_page
69
+ end
70
+
71
+ private
72
+
73
+ def load_page
74
+ @records = ordered_scope.limit(@per_page).offset((@page - 1) * @per_page).to_a
75
+ end
76
+
77
+ def ordered_scope
78
+ s = @scope.reset
79
+ if s.order_values.empty?
80
+ s.order(s.klass.arel_table[s.klass.primary_key])
81
+ else
82
+ s
83
+ end
84
+ end
85
+
86
+ def row_hash(record)
87
+ columns.each_with_object({}) do |col, hash|
88
+ hash[col.key] = record.public_send(col.key)
89
+ end
90
+ end
91
+
92
+ READONLY_COLUMNS = %w[id created_at updated_at].freeze
93
+
94
+ def infer_columns
95
+ model = @scope.klass
96
+ model.column_names.map do |name|
97
+ col = model.columns_hash[name]
98
+ Column.new(
99
+ key: name.to_sym,
100
+ type: map_ar_type(col.type),
101
+ width: guess_width(name, col.type),
102
+ editable: !READONLY_COLUMNS.include?(name)
103
+ )
104
+ end
105
+ end
106
+
107
+ def map_ar_type(ar_type)
108
+ case ar_type
109
+ when :text then :text
110
+ else :string
111
+ end
112
+ end
113
+
114
+ def guess_width(name, ar_type)
115
+ case ar_type
116
+ when :text then nil
117
+ when :integer then 12
118
+ when :boolean then 8
119
+ when :datetime, :timestamp then 22
120
+ when :date then 12
121
+ else
122
+ name == "id" ? 8 : nil
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ class TableView
5
+ attr_reader :data_source, :table_state
6
+
7
+ def initialize(data_source)
8
+ @data_source = data_source
9
+ @table_state = RatatuiRuby::TableState.new(0)
10
+ end
11
+
12
+ def render(tui, frame, area)
13
+ columns = data_source.columns
14
+ rows = data_source.rows
15
+
16
+ header = columns.map(&:label)
17
+
18
+ table_rows = rows.map do |row|
19
+ columns.map { |col| format_cell(row[col.key], col) }
20
+ end
21
+
22
+ widths = compute_widths(columns, area.width)
23
+
24
+ table = tui.table(
25
+ header: header,
26
+ rows: table_rows,
27
+ widths: widths,
28
+ row_highlight_style: {bg: :dark_gray, modifiers: [:bold]},
29
+ highlight_symbol: "> ",
30
+ block: tui.block(
31
+ title: " #{title_text} ",
32
+ borders: [:all],
33
+ border_type: :rounded,
34
+ border_style: {fg: :cyan}
35
+ ),
36
+ style: {fg: :white}
37
+ )
38
+
39
+ frame.render_stateful_widget(table, area, @table_state)
40
+ end
41
+
42
+ def selected_index
43
+ @table_state.selected
44
+ end
45
+
46
+ def selected_row
47
+ idx = selected_index
48
+ return nil if idx.nil? || idx >= data_source.row_count
49
+ data_source.rows[idx]
50
+ end
51
+
52
+ def select_next
53
+ max = data_source.row_count - 1
54
+ return if max < 0
55
+ return if (selected_index || 0) >= max
56
+ @table_state.select_next
57
+ end
58
+
59
+ def select_previous
60
+ return if (selected_index || 0) <= 0
61
+ @table_state.select_previous
62
+ end
63
+
64
+ def select_first
65
+ @table_state.select_first
66
+ end
67
+
68
+ def select_last
69
+ max = data_source.row_count - 1
70
+ @table_state.select(max) if max >= 0
71
+ end
72
+
73
+ def reset_selection
74
+ @table_state.select(0)
75
+ end
76
+
77
+ private
78
+
79
+ def title_text
80
+ ds = data_source
81
+ if ds.paginated?
82
+ "Records (page #{ds.page}/#{ds.total_pages}, #{ds.total_count} total)"
83
+ else
84
+ "Records (#{ds.row_count})"
85
+ end
86
+ end
87
+
88
+ def format_cell(value, column)
89
+ text = value.to_s
90
+ case column.type
91
+ when :text
92
+ # Truncate long text for table display
93
+ text.length > 40 ? "#{text[0, 37]}..." : text
94
+ else
95
+ text
96
+ end
97
+ end
98
+
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
+ columns.map do |col|
105
+ if col.width
106
+ RatatuiRuby::Layout::Constraint.length(col.width)
107
+ else
108
+ RatatuiRuby::Layout::Constraint.fill(1)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ UpdateResult = Data.define(:ok, :errors) do
5
+ def initialize(ok:, errors: [])
6
+ super
7
+ end
8
+
9
+ def ok? = ok
10
+ def failed? = !ok
11
+
12
+ def self.success
13
+ new(ok: true)
14
+ end
15
+
16
+ def self.failure(errors)
17
+ new(ok: false, errors: Array(errors))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ratamin
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ratamin.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ratatui_ruby"
4
+ require "zeitwerk"
5
+
6
+ loader = Zeitwerk::Loader.for_gem
7
+ loader.setup
8
+
9
+ module Ratamin
10
+ class Error < StandardError; end
11
+ end
data/sig/ratamin.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ratamin
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ratamin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - merely
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ratatui_ruby
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.4'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.4'
26
+ - !ruby/object:Gem::Dependency
27
+ name: zeitwerk
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.6'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.6'
40
+ description: A terminal UI for browsing and editing ActiveRecord scopes, built on
41
+ ratatui_ruby.
42
+ email:
43
+ - git@merely.ca
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".github/workflows/ci.yml"
49
+ - ".gitmodules"
50
+ - CHANGELOG.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - examples/demo.rb
55
+ - lib/ratamin.rb
56
+ - lib/ratamin/app.rb
57
+ - lib/ratamin/array_data_source.rb
58
+ - lib/ratamin/column.rb
59
+ - lib/ratamin/data_source.rb
60
+ - lib/ratamin/editor_bridge.rb
61
+ - lib/ratamin/form_state.rb
62
+ - lib/ratamin/form_view.rb
63
+ - lib/ratamin/scope_data_source.rb
64
+ - lib/ratamin/table_view.rb
65
+ - lib/ratamin/update_result.rb
66
+ - lib/ratamin/version.rb
67
+ - sig/ratamin.rbs
68
+ homepage: https://github.com/onyxblade/ratamin
69
+ licenses:
70
+ - MIT
71
+ metadata:
72
+ homepage_uri: https://github.com/onyxblade/ratamin
73
+ source_code_uri: https://github.com/onyxblade/ratamin
74
+ changelog_uri: https://github.com/onyxblade/ratamin/blob/main/CHANGELOG.md
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.3.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.3
90
+ specification_version: 4
91
+ summary: TUI admin console for ActiveRecord
92
+ test_files: []