mittens_ui 0.0.14 → 0.0.16

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2479146dca0d7b1cacd610a6c50d8d04df41c8678db14f1b97950ab62b377a63
4
- data.tar.gz: 3d86bd7ce738e1a530bd983d26a17a6fe4f2255dc3654d4f203624ceaa634138
3
+ metadata.gz: ce8a7d0aa1723a2134b6364e0e6fda2c2b9cd4e91b4e05c4f4ca6e948428c643
4
+ data.tar.gz: 34d9f15cb0cae6dedbc33bd62a8e2a137c06323d196fb607295ddd47b66196a2
5
5
  SHA512:
6
- metadata.gz: 5e780dfaf16f6657fb955e664470c2ba816290b4d2a05d0f002cb8b810008d6dc7fc92c5cd0115bd10176ca83fafff55f9833de65c8006f37e8f479357137021
7
- data.tar.gz: accbcf0f8c5f3114fe5a974a268c4c479bc067eb2aae596450e0e7fa074090c5f81b8a306fbd2028f6a039109781adbe9c784076b2564bb16a04f851024d7cd0
6
+ metadata.gz: 3528b40c3376faf23d93874d84247cfa6bcfa4b59f932a3e6000090619aa26c7dba5a2ff617f8530ca1cb62096f97ac533a4235da5ba91479e2dc9fa2601bb9d
7
+ data.tar.gz: b1c75473975e722ca380dda49dba9566128bc8becae1e6937a7e02a543361b367f5eb57a7b9c6837e980c95e7fa837583ce85a20c55c076ff4c3b664789ab932
data/Gemfile CHANGED
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- ruby "4.0.0"
3
+ ruby "4.0.3"
4
4
 
5
5
  # Specify your gem's dependencies in mittens_ui.gemspec
6
6
  gemspec
data/README.md CHANGED
@@ -243,27 +243,14 @@ img = MittensUi::Image.new("./assets/animation.gif")
243
243
  ### TableView
244
244
  ```ruby
245
245
  table = MittensUi::TableView.new(
246
- headers: ["Name", "Email"],
247
- data: [
248
- ["John", "john@example.com"],
249
- ["Jane", "jane@example.com"]
250
- ],
246
+ ['Name', 'Email'],
247
+ [['John', 'john@example.com']]
251
248
  )
252
249
 
253
- table.add(["Bob", "bob@example.com"])
254
- table.add(["First", "first@example.com"], :prepend)
250
+ table.add(['Jane', 'jane@example.com'])
255
251
 
256
- table.remove_selected
257
-
258
- puts table.row_count
259
-
260
- puts table.selected_row.inspect
261
-
262
- # Single click on the row
263
252
  table.row_clicked { |row| puts row.inspect }
264
-
265
- # Double click on the row
266
- table.row_double_clicked { |row| puts row.inspect }
253
+ table.row_double_clicked { |row| puts "Double: #{row.inspect}" }
267
254
  ```
268
255
 
269
256
  ### Alert
data/examples/contacts.rb CHANGED
@@ -2,6 +2,7 @@ require '../lib/mittens_ui'
2
2
 
3
3
  app_options = { name: 'contacts', title: 'Contacts', height: 680, width: 600, can_resize: true }.freeze
4
4
 
5
+
5
6
  MittensUi::Application.Window(app_options) do
6
7
  puts MittensUi::Application.store.get(:last_selected_contact)
7
8
 
@@ -25,63 +26,39 @@ MittensUi::Application.Window(app_options) do
25
26
  # --- Contacts Table ---
26
27
  MittensUi::Separator.new(:horizontal, top: 2, bottom: 2)
27
28
 
28
- contacts_table = MittensUi::TableView.new(
29
- ['Name', 'Email', 'Phone'],
30
- [
31
- ['John Appleseed', 'john@example.com', '555-1234'],
32
- ['Jane Doe', 'jane@example.com', '555-5678'],
33
- ['Michael Smith', 'michael.smith@email.com', '555-1001'],
34
- ['Emily Johnson', 'emily.j@email.com', '555-1002'],
35
- ['Chris Evans', 'cevans@email.com', '555-1003'],
36
- ['Olivia Brown', 'olivia.b@email.com', '555-1004'],
37
- ['Daniel Wilson', 'dan.w@email.com', '555-1005'],
38
- ['Sophia Martinez', 'sophia.m@email.com', '555-1006'],
39
- ['James Anderson', 'j.anderson@email.com', '555-1007'],
40
- ['Isabella Thomas', 'isabella.t@email.com', '555-1008'],
41
- ['Benjamin Taylor', 'ben.taylor@email.com', '555-1009'],
42
- ['Mia Moore', 'mia.moore@email.com', '555-1010'],
43
- ['Lucas Jackson', 'lucas.j@email.com', '555-1011'],
44
- ['Charlotte White', 'charlotte.w@email.com', '555-1012'],
45
- ['Henry Harris', 'henry.h@email.com', '555-1013'],
46
- ['Amelia Martin', 'amelia.m@email.com', '555-1014'],
47
- ['Alexander Thompson', 'alex.t@email.com', '555-1015'],
48
- ['Evelyn Garcia', 'evelyn.g@email.com', '555-1016'],
49
- ['William Martinez', 'will.m@email.com', '555-1017'],
50
- ['Harper Robinson', 'harper.r@email.com', '555-1018'],
51
- ['Daniel Clark', 'dan.clark@email.com', '555-1019'],
52
- ['Abigail Rodriguez', 'abigail.r@email.com', '555-1020'],
53
- ['Matthew Lewis', 'matt.lewis@email.com', '555-1021'],
54
- ['Ella Lee', 'ella.lee@email.com', '555-1022'],
55
- ['David Walker', 'd.walker@email.com', '555-1023'],
56
- ['Scarlett Hall', 'scarlett.h@email.com', '555-1024'],
57
- ['Joseph Allen', 'j.allen@email.com', '555-1025'],
58
- ['Grace Young', 'grace.y@email.com', '555-1026'],
59
- ['Samuel King', 'sam.king@email.com', '555-1027'],
60
- ['Chloe Wright', 'chloe.w@email.com', '555-1028'],
61
- ['Andrew Scott', 'andrew.s@email.com', '555-1029'],
62
- ['Victoria Green', 'victoria.g@email.com', '555-1030'],
63
- ['Joshua Adams', 'josh.adams@email.com', '555-1031'],
64
- ['Lily Baker', 'lily.b@email.com', '555-1032'],
65
- ['Ryan Nelson', 'ryan.n@email.com', '555-1033'],
66
- ['Zoey Carter', 'zoey.c@email.com', '555-1034'],
67
- ['Nathan Mitchell', 'nathan.m@email.com', '555-1035'],
68
- ['Hannah Perez', 'hannah.p@email.com', '555-1036'],
69
- ['Aaron Roberts', 'aaron.r@email.com', '555-1037'],
70
- ['Sofia Turner', 'sofia.t@email.com', '555-1038'],
71
- ['Caleb Phillips', 'caleb.p@email.com', '555-1039'],
72
- ['Avery Campbell', 'avery.c@email.com', '555-1040'],
73
- ['Ethan Parker', 'ethan.p@email.com', '555-1041'],
74
- ['Madison Evans', 'madison.e@email.com', '555-1042'],
75
- ['Logan Edwards', 'logan.e@email.com', '555-1043'],
76
- ['Layla Collins', 'layla.c@email.com', '555-1044'],
77
- ['Noah Stewart', 'noah.s@email.com', '555-1045'],
78
- ['Riley Sanchez', 'riley.s@email.com', '555-1046'],
79
- ['Jack Morris', 'jack.m@email.com', '555-1047'],
80
- ['Aria Rogers', 'aria.r@email.com', '555-1048'],
81
- ['Sebastian Reed', 'sebastian.r@email.com', '555-1049'],
82
- ['Nora Cook', 'nora.c@email.com', '555-1050']
29
+ first_names = %w[
30
+ John Jane Michael Emily Chris Olivia Daniel Sophia James Isabella
31
+ Benjamin Mia Lucas Charlotte Henry Amelia Alexander Evelyn William Harper
32
+ Daniel Abigail Matthew Ella David Scarlett Joseph Grace Samuel Chloe
33
+ Andrew Victoria Joshua Lily Ryan Zoey Nathan Hannah Aaron Sofia
34
+ Caleb Avery Ethan Madison Logan Layla Noah Riley Jack Aria
35
+ Sebastian Nora Liam Ava Mason Ella Ethan Harper Logan Grace
36
+ ]
37
+
38
+ last_names = %w[
39
+ Smith Johnson Brown Taylor Anderson Thomas Jackson White Harris Martin
40
+ Thompson Garcia Martinez Robinson Clark Rodriguez Lewis Lee Walker Hall
41
+ Allen Young King Wright Scott Green Adams Baker Nelson Carter Mitchell
42
+ Perez Roberts Turner Phillips Campbell Parker Evans Edwards Collins Stewart
43
+ Sanchez Morris Rogers Reed Cook Morgan Bell Murphy Bailey Rivera Cooper
83
44
  ]
84
- )
45
+
46
+ contacts_data = 1000.times.map do |i|
47
+ first = first_names.sample
48
+ last = last_names.sample
49
+
50
+ name = "#{first} #{last}"
51
+ email = "#{first.downcase}.#{last.downcase}#{i}@example.com"
52
+ phone = "555-#{1000 + i}"
53
+
54
+ [name, email, phone]
55
+ end
56
+
57
+ contacts_table = MittensUi::TableView.new(
58
+ ['Name', 'Email', 'Phone'],
59
+ contacts_data
60
+ )
61
+
85
62
  contacts_table.row_double_clicked do |row|
86
63
  puts "Double clicked: #{row.inspect}"
87
64
  end
data/examples/hn.rb ADDED
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require '../lib/mittens_ui'
7
+
8
+ class HackerNewsClient
9
+ BASE_URL = 'https://hacker-news.firebaseio.com/v0'
10
+
11
+ def self.get_json(path)
12
+ uri = URI("#{BASE_URL}/#{path}")
13
+ res = Net::HTTP.get_response(uri)
14
+ JSON.parse(res.body)
15
+ end
16
+
17
+ def self.top_story_ids
18
+ get_json('topstories.json')
19
+ end
20
+
21
+ def self.get_item(id)
22
+ get_json("item/#{id}.json")
23
+ end
24
+
25
+ def self.top_stories(limit = 20)
26
+ top_story_ids.first(limit).map do |id|
27
+ get_item(id)
28
+ end
29
+ end
30
+
31
+ def self.map_stories_to_rows(stories_data)
32
+ stories_data.map do |story|
33
+ url = story['url'] || "https://news.ycombinator.com/item?id=#{story['id']}"
34
+
35
+ [
36
+ story['title'] || 'N/A',
37
+ story['by'] || 'N/A',
38
+ story['score'] || 0,
39
+ url
40
+ ]
41
+ end
42
+ end
43
+ end
44
+
45
+ MittensUi::Application.Window(
46
+ name: 'hn_viewer',
47
+ title: 'HN - Top Hacker News Stories',
48
+ width: 900,
49
+ height: 600
50
+ ) do
51
+
52
+ stories = HackerNewsClient.top_stories(100)
53
+ rows = HackerNewsClient.map_stories_to_rows(stories)
54
+
55
+ top_stories_table = MittensUi::TableView.new(
56
+ %w[Title Author Score URL].freeze,
57
+ rows,
58
+ { page_threshold: 20, page_size: 10 }.freeze
59
+ )
60
+
61
+ top_stories_table.row_double_clicked do |row|
62
+ link = MittensUi::WebLink.new('', row[3])
63
+ link.open_url
64
+ link.remove
65
+ end
66
+
67
+ MittensUi::Button.new(title: 'Refresh Stories').click do |btn|
68
+ btn.loading do
69
+ stories = HackerNewsClient.top_stories(5)
70
+ top_stories_table.update_data(HackerNewsClient.map_stories_to_rows(stories))
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,71 @@
1
+ require '../lib/mittens_ui'
2
+
3
+ app_options = {
4
+ name: 'shortcut_demo',
5
+ title: 'Keyboard Shortcut Demo',
6
+ height: 400,
7
+ width: 500,
8
+ can_resize: false,
9
+ theme: :light
10
+ }.freeze
11
+
12
+ MittensUi::Application.Window(app_options) do
13
+
14
+ press_counter = 0
15
+ status = MittensUi::Label.new('Press a shortcut key...', expand: true, margin: 12)
16
+
17
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
18
+
19
+ d_btn = MittensUi::Button.new(title: 'Focus me → ctrl+d (instant)')
20
+ d_btn.keyboard_shortcut("ctrl", "d") do
21
+ press_counter += 1
22
+ status.text = "ctrl+d fired! x#{press_counter.to_s}"
23
+ end
24
+
25
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
26
+
27
+
28
+ s_btn = MittensUi::Button.new(title: 'Focus me → ctrl+s (2s delay)')
29
+ s_btn.keyboard_shortcut("ctrl", "s", timer: 2) do
30
+ press_counter += 1
31
+ status.text = "ctrl+s fired after 2s! x#{press_counter}"
32
+ end
33
+
34
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
35
+
36
+ tb = MittensUi::Textbox.new(multiline: false, width: :full, placeholder: "write text, press alt+r to clear it!")
37
+ tb.keyboard_shortcut("alt", "r") do
38
+ tb.text = ""
39
+ press_counter += 1
40
+ status.text = "alt+r fired — text field cleared! x#{press_counter}"
41
+ end
42
+
43
+ # can register multiple keyboard_shortcuts for the same Widget.
44
+ tb.keyboard_shortcut("alt", "u") do
45
+ tb.text = tb.text.upcase
46
+ press_counter += 1
47
+ status.text = "alt+u fired - text uppercased! x#{press_counter}"
48
+ end
49
+
50
+ tb.keyboard_shortcut("alt", "d") do
51
+ tb.text = tb.text.downcase
52
+ press_counter += 1
53
+ status.text = "alt+d fired - text downcased! x#{press_counter}"
54
+ end
55
+
56
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
57
+
58
+ all = []
59
+ inspection_button = MittensUi::Button.new(title: 'Inspect Shortcuts')
60
+
61
+ inspection_button.click do
62
+ all = [d_btn.shortcuts, s_btn.shortcuts, tb.shortcuts].flatten
63
+ status.text = "Registered: #{all.join(', ')}"
64
+ end
65
+
66
+ inspection_button.keyboard_shortcut("ctrl", "s") do
67
+ status.text = "Registered: #{ all.map{ |cmd| cmd.upcase }.join(', ') }"
68
+ end
69
+
70
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
71
+ end
data/examples/mig.rb CHANGED
@@ -109,4 +109,17 @@ MittensUi::Application.Window(app_options) do
109
109
  MittensUi::Separator.new(:horizontal, top: 10, bottom: 100)
110
110
  MittensUi::RadioButton.new({ options: %w[Red Green Blue], layout: :horizontal, bottom: 50 })
111
111
 
112
+ MittensUi::Separator.new(:horizontal, top: 10, bottom: 10)
113
+
114
+ status = MittensUi::Label.new('Press a shortcut key...', expand: true, margin: 12)
115
+
116
+ MittensUi::Separator.new(:horizontal, top: 8, bottom: 8)
117
+
118
+ # Ctrl+D fires instantly on the button
119
+ d_btn = MittensUi::Button.new(title: 'Focus me → Ctrl+D (instant)')
120
+ d_btn.keyboard_shortcut("ctrl", "d") do
121
+ status.text = "Ctrl+D fired!"
122
+ end
123
+
124
+
112
125
  end
@@ -38,8 +38,8 @@ module MittensUi
38
38
  # @option options [Symbol] :width (:full) column width in the layout grid
39
39
  # @option options [Boolean] :defer_render (false) skip auto-rendering into layout
40
40
  def initialize(options = {})
41
- button_title = options[:title] || 'Button'
42
- icon_type = options[:icon] || nil
41
+ button_title = options.fetch(:title, 'Button')
42
+ icon_type = options.fetch(:icon, nil)
43
43
 
44
44
  @loading = false
45
45
  @button = Gtk::Button.new
@@ -53,7 +53,9 @@ module MittensUi
53
53
 
54
54
  @label = Gtk::Label.new(button_title)
55
55
  @box.append(@label)
56
+ @box.set_halign(:center)
56
57
  @box.append(@spinner)
58
+
57
59
  @button.set_child(@box)
58
60
  @spinner.hide
59
61
 
@@ -24,7 +24,7 @@ module MittensUi
24
24
  # @option options [Symbol] :width (:full) column width in the layout grid
25
25
  # @option options [Boolean] :defer_render (false) skip auto-rendering into layout
26
26
  def initialize(options = {})
27
- label = options[:label] || 'Checkbox'
27
+ label = options.fetch(:label, 'Checkbox')
28
28
  @value = nil
29
29
  @checkbox = Gtk::CheckButton.new
30
30
  @checkbox.set_label(label.to_s)
@@ -38,9 +38,9 @@ module MittensUi
38
38
  # @yield [picker] called only if the user selected a color
39
39
  # @yieldparam picker [MittensUi::ColorPicker] the picker with color data
40
40
  def initialize(options = {}, &block)
41
- @title = options[:title] || 'Pick a Color'
42
- @default = options[:default] || nil
43
- @alpha = options[:alpha] || false
41
+ @title = options.fetch(:title, 'Pick a Color')
42
+ @default = options.fetch(:default, nil)
43
+ @alpha = options.fetch(:alpha, false)
44
44
  @selected = false
45
45
  @color = nil
46
46
 
@@ -49,11 +49,13 @@ module MittensUi
49
49
  # @option options [Integer] :bottom bottom margin in pixels
50
50
  # @option options [Integer] :right right margin in pixels
51
51
  def initialize(widget, options = {})
52
- @core_widget = widget
52
+ @core_widget = widget # This represents the GTK widget.
53
+ @core_widget.focusable = true
53
54
  @width = options[:width] || :full
54
55
  @defer_render = options[:defer_render] || false
55
56
  set_margin_from_opts_for(@core_widget, options)
56
57
  render unless @defer_render
58
+ enable_hover_focus
57
59
  end
58
60
 
59
61
  # Shows the widget if it is hidden.
@@ -108,7 +110,7 @@ module MittensUi
108
110
  # Adds the widget to the application layout grid.
109
111
  # Called automatically during initialization unless +:defer_render+ is true.
110
112
  # Can be overridden in subclasses that require special placement
111
- # (e.g. {HeaderBar}, {Notify}).
113
+ # ({HeaderBar}, {Notify}).
112
114
  #
113
115
  # @return [void]
114
116
  def render
@@ -119,5 +121,111 @@ module MittensUi
119
121
  MittensUi::Application.layout.add(@core_widget, width: @width)
120
122
  end
121
123
  end
124
+
125
+
126
+ # Registers a simple keyboard shortcut on +@core_wiget+ using a modifier + key combination.
127
+ #
128
+ # Attaches a +Gtk::EventControllerKey+ to the widget that listens for the given
129
+ # key combination. The widget must have focus for the shortcut to fire. If a
130
+ # +timer+ is provided, the block is executed after the specified delay via
131
+ # +GLib::Timeout+.
132
+ #
133
+ # @param modifier [String] The modifier key. Accepts "ctrl", "shift", "alt", or "super".
134
+ # @param key [String] The key name as a GTK key string ("d", "s", "Return", "Escape").
135
+ # @param timer [Integer] Seconds to wait before invoking the block. Defaults to 0 (immediate).
136
+ # @param block [Proc] The block to execute when the shortcut is triggered.
137
+ #
138
+ # @example Instant shortcut
139
+ # btn.keyboard_shortcut("ctrl", "d") { puts "Ctrl+D fired" }
140
+ #
141
+ # @example Delayed shortcut
142
+ # btn.keyboard_shortcut("ctrl", "s", timer: 2) { puts "fired after 2s" }
143
+ #
144
+ # @return [void]
145
+ def keyboard_shortcut(modifier, key, timer: 0, &block)
146
+ @shortcut_controllers ||= []
147
+
148
+ controller = Gtk::EventControllerKey.new
149
+
150
+ controller.signal_connect("key-pressed") do |_ctrl, keyval, _keycode, state|
151
+ mod_match = case modifier.to_s.downcase
152
+ when "ctrl" then (state & Gdk::ModifierType::CONTROL_MASK).nonzero?
153
+ when "shift" then (state & Gdk::ModifierType::SHIFT_MASK).nonzero?
154
+ when "alt" then (state & Gdk::ModifierType::ALT_MASK).nonzero?
155
+ when "super" then (state & Gdk::ModifierType::SUPER_MASK).nonzero?
156
+ else false
157
+ end
158
+
159
+ key_match = (keyval == Gdk::Keyval.from_name(key))
160
+
161
+ if mod_match && key_match
162
+ if timer > 0
163
+ GLib::Timeout.add(timer * 1000) do
164
+ block.call
165
+ GLib::Source::REMOVE
166
+ end
167
+ else
168
+ block.call
169
+ end
170
+ true # consume the event
171
+ else
172
+ false
173
+ end
174
+ end
175
+
176
+ @shortcut_controllers << { modifier: modifier, key: key, controller: controller }
177
+ @core_widget.add_controller(controller)
178
+ end
179
+
180
+ # Removes a previously registered keyboard shortcut from +@core_widget+.
181
+ #
182
+ # Finds the shortcut matching the given +modifier+ and +key+ combination,
183
+ # detaches its +Gtk::EventControllerKey+ from the underlying GTK widget.
184
+ #
185
+ # If no matching shortcut is found, this is a no-op.
186
+ #
187
+ # @param modifier [String] The modifier key used when registering the shortcut ("ctrl").
188
+ # @param key [String] The key name used when registering the shortcut ("d").
189
+ #
190
+ # @example
191
+ # btn.remove_shortcut("ctrl", "d")
192
+ #
193
+ # @return [void]
194
+ def remove_keyboard_shortcut(modifier, key)
195
+ @shortcut_controllers&.reject! do |entry|
196
+ if entry[:modifier] == modifier && entry[:key] == key
197
+ @gtk_widget.remove_controller(entry[:controller])
198
+ true
199
+ end
200
+ end
201
+ end
202
+
203
+ # Returns a list of all keyboard shortcuts registered on +@core_wiget+.
204
+ #
205
+ # Each shortcut is represented as a human-readable string in the format
206
+ # "modifier+key" ("ctrl+d", "alt+r").
207
+ #
208
+ # @example
209
+ # btn.shortcuts # => ["ctrl+d", "ctrl+s"]
210
+ #
211
+ # @return [Array<String>] Registered shortcuts, or an empty array if none.
212
+ def shortcuts
213
+ @shortcut_controllers&.map { |e| "#{e[:modifier]}+#{e[:key]}" } || []
214
+ end
215
+
216
+ private
217
+
218
+ # Grants focus to the +@core_wiget+ when the mouse pointer enters it.
219
+ #
220
+ # Attaches a +Gtk::EventControllerMotion+ and sets +focusable+ to +true+,
221
+ # which is required for +grab_focus+ to work with GTK4 wigets.
222
+ #
223
+ # @return [void]
224
+ def enable_hover_focus
225
+ @core_widget.focusable = true
226
+ motion = Gtk::EventControllerMotion.new
227
+ motion.signal_connect("enter") { @core_widget.grab_focus }
228
+ @core_widget.add_controller(motion)
229
+ end
122
230
  end
123
231
  end
@@ -25,8 +25,8 @@ module MittensUi
25
25
  # Accepted values are +:left+ and +:right+
26
26
  # @option options [Boolean] :defer_render (false) skip auto-rendering into layout
27
27
  def initialize(widgets, options = {})
28
- title = options[:title] || ''
29
- position = options[:position] || :left
28
+ title = options.fetch(:title, '')
29
+ position = options.fetch(:position, :left)
30
30
 
31
31
  @header = Gtk::HeaderBar.new
32
32
 
@@ -36,9 +36,8 @@ module MittensUi
36
36
  title_label = Gtk::Label.new(title)
37
37
  @header.title_widget = title_label
38
38
 
39
-
40
39
  box = Gtk::Box.new(:horizontal, 0)
41
- box.add_css_class("linked")
40
+ box.add_css_class('linked')
42
41
 
43
42
  widgets.each do |w|
44
43
  w.remove
@@ -210,7 +210,7 @@ module MittensUi
210
210
 
211
211
  # value text
212
212
  cr.set_source_rgb(0.9, 0.9, 0.9)
213
- cr.set_font_size(9)
213
+ cr.set_font_size(10.4)
214
214
  text = value.to_s
215
215
  extents = cr.text_extents(text)
216
216
  cr.move_to(cx - extents.width / 2, cy + extents.height / 2)
@@ -32,5 +32,15 @@ module MittensUi
32
32
  gtk_label = Gtk::Label.new(text)
33
33
  super(gtk_label, options)
34
34
  end
35
+
36
+ # @return [String] returns the current label text.
37
+ def text
38
+ @core_widget.label
39
+ end
40
+
41
+ # @param value [String] A String value that the label gets set to
42
+ def text=(value)
43
+ @core_widget.set_label(value.to_s)
44
+ end
35
45
  end
36
46
  end
@@ -3,13 +3,14 @@
3
3
  require 'mittens_ui/core'
4
4
 
5
5
  module MittensUi
6
- # A simple, but custom table widget built on {Gtk::Grid}.
6
+ # A simple, custom table widget built on Gtk::Grid.
7
7
  #
8
- # Provides:
9
- # - Column headers with sorting (▲ ▼ indicators)
10
- # - Row selection
11
- # - Single-click and double-click callbacks
12
- # - Dynamic row insertion
8
+ # Features:
9
+ # - Sorting with ▲ ▼ indicators
10
+ # - Row selection (mouse + keyboard)
11
+ # - Single & double click callbacks
12
+ # - Pagination for large datasets (auto-enabled > 500 rows)
13
+ # - Built-in pagination UI (Prev / Next buttons + page indicator)
13
14
  # - Dark mode friendly styling
14
15
  #
15
16
  # @example Basic usage
@@ -21,23 +22,31 @@ module MittensUi
21
22
  # @example Add row
22
23
  # table.add(['Jane', 'jane@example.com'])
23
24
  #
24
- # @example Click handlers
25
- # table.row_clicked { |row| puts row.inspect }
26
- # table.row_double_clicked { |row| puts "Double: #{row.inspect}" }
25
+ # @example Pagination (automatic)
26
+ # # Pagination UI appears automatically when data > 500 rows, unless you change it by using options hash.
27
27
  #
28
28
  class TableView < Core
29
29
  attr_reader :data, :headers, :selected_row_idx
30
30
 
31
- # @param headers [Array<String>] column headers
32
- # @param data [Array<Array<String>>] initial rows
33
- # @param options [Hash] Core layout options
31
+ PAGE_THRESHOLD = 500
32
+ PAGE_SIZE = 100
33
+
34
34
  def initialize(headers = [], data = [], options = {})
35
35
  @headers = headers
36
36
  @data = data
37
+
37
38
  @row_widgets = []
38
39
  @header_labels = []
39
40
  @selected_row_idx = nil
40
41
  @sort_directions = {}
42
+ @current_page = 0
43
+
44
+ @page_threshold = options.fetch(:page_threshold, PAGE_THRESHOLD)
45
+ @page_size = options.fetch(:page_size, PAGE_SIZE)
46
+
47
+ # ---------------------------
48
+ # GTK Structure
49
+ # ---------------------------
41
50
 
42
51
  @grid = Gtk::Grid.new
43
52
  @grid.set_column_spacing(0)
@@ -46,102 +55,147 @@ module MittensUi
46
55
  @scroller = Gtk::ScrolledWindow.new
47
56
  @scroller.set_policy(:automatic, :automatic)
48
57
  @scroller.set_child(@grid)
58
+ @scroller.set_vexpand(true)
59
+ @scroller.set_min_content_height(300)
60
+ @scroller.set_max_content_height(300)
61
+
62
+ # Pagination UI
63
+ @pagination_box = Gtk::Box.new(:horizontal, 10)
64
+ @pagination_box.set_margin_top(10)
65
+ @pagination_box.set_halign(:center)
49
66
 
50
- # Calculate height of table:
51
- row_height = 24
52
- header_height = 40
53
- max_height = 300
54
- desired_height = [(@data.size * row_height) + header_height, max_height].min
67
+ @prev_btn = Gtk::Button.new(label: '← Prev')
68
+ @next_btn = Gtk::Button.new(label: 'Next →')
69
+ @page_label = Gtk::Label.new('')
55
70
 
56
- # Set the scroller's min and max height
57
- @scroller.set_min_content_height(desired_height)
58
- @scroller.set_max_content_height(desired_height)
71
+ @prev_btn.signal_connect('clicked') { prev_page }
72
+ @next_btn.signal_connect('clicked') { next_page }
59
73
 
60
- super(@scroller, options)
74
+ @pagination_box.append(@prev_btn)
75
+ @pagination_box.append(@page_label)
76
+ @pagination_box.append(@next_btn)
61
77
 
62
- # Click handling
78
+ # Root container (important for Core)
79
+ @container = Gtk::Box.new(:vertical, 0)
80
+ @container.append(@scroller)
81
+ @container.append(@pagination_box)
82
+
83
+ super(@container, options)
84
+
85
+ # Events
63
86
  @on_row_clicked = nil
64
87
  @on_row_double_clicked = nil
65
88
  @last_click_time = nil
66
89
  @last_clicked_row = nil
67
90
 
68
91
  setup_css
92
+ setup_keyboard
93
+
69
94
  render_headers
70
95
  render_rows
96
+ update_pagination_ui
71
97
  end
72
98
 
73
99
  # ---------------------------
74
100
  # Public API
75
101
  # ---------------------------
76
102
 
77
- # Add a row to the table
78
- #
79
- # @param row [Array<String>] row data
80
- # @param direction [Symbol] :append or :prepend
81
- # @return [void]
82
103
  def add(row, direction = :append)
83
104
  return if row.nil? || row.empty?
84
105
 
85
- if direction == :prepend
86
- @data.unshift(row)
87
- else
88
- @data << row
89
- end
106
+ direction == :prepend ? @data.unshift(row) : @data << row
90
107
 
108
+ adjust_page_after_insert
91
109
  render_rows
110
+ update_pagination_ui
92
111
  end
93
112
 
94
- # Replace table data
95
- #
96
- # @param new_data [Array<Array<String>>]
97
113
  def update_data(new_data)
98
114
  @data = new_data
115
+ @current_page = 0
99
116
  render_rows
117
+ update_pagination_ui
100
118
  end
101
119
 
102
- # Check if a row is selected
103
- #
104
- # @param idx [Integer, nil]
105
- # @return [Boolean]
106
120
  def row_selected?(idx = nil)
107
121
  return !@selected_row_idx.nil? if idx.nil?
122
+
108
123
  @selected_row_idx == idx
109
124
  end
110
125
 
111
- # Get selected row data
112
- #
113
- # @return [Array<String>, nil]
114
126
  def selected_row
115
127
  return nil unless @selected_row_idx
128
+
116
129
  @data[@selected_row_idx]
117
130
  end
118
131
 
119
- # Remove selected row
120
- #
121
- # @return [Array<String>, nil]
122
132
  def remove_selected
123
133
  return nil unless @selected_row_idx
124
134
 
125
135
  removed = @data.delete_at(@selected_row_idx)
126
136
  @selected_row_idx = nil
127
137
  render_rows
138
+ update_pagination_ui
128
139
  removed
129
140
  end
130
141
 
131
- # Register single-click handler
132
- #
133
- # @yield [row] row data
134
142
  def row_clicked(&block)
135
143
  @on_row_clicked = block
136
144
  end
137
145
 
138
- # Register double-click handler
139
- #
140
- # @yield [row] row data
141
146
  def row_double_clicked(&block)
142
147
  @on_row_double_clicked = block
143
148
  end
144
149
 
150
+ # ---------------------------
151
+ # Pagination
152
+ # ---------------------------
153
+
154
+ def paginated_data
155
+ return @data if @data.size <= @page_threshold
156
+
157
+ start = @current_page * @page_size
158
+ @data.slice(start, @page_size) || []
159
+ end
160
+
161
+ def next_page
162
+ return if @data.size <= @page_threshold
163
+
164
+ max_page = (@data.size / @page_size.to_f).ceil - 1
165
+ @current_page = [@current_page + 1, max_page].min
166
+ render_rows
167
+ update_pagination_ui
168
+ end
169
+
170
+ def prev_page
171
+ return if @data.size <= @page_threshold
172
+
173
+ @current_page = [@current_page - 1, 0].max
174
+ render_rows
175
+ update_pagination_ui
176
+ end
177
+
178
+ def adjust_page_after_insert
179
+ return if @data.size <= @page_threshold
180
+
181
+ @current_page = (@data.size / @page_size.to_f).floor
182
+ end
183
+
184
+ def update_pagination_ui
185
+ if @data.size <= @page_threshold
186
+ @pagination_box.hide
187
+ return
188
+ end
189
+
190
+ total_pages = (@data.size / @page_size.to_f).ceil
191
+ @page_label.set_label("#{@current_page + 1} / #{total_pages}")
192
+
193
+ @prev_btn.set_sensitive(@current_page > 0)
194
+ @next_btn.set_sensitive(@current_page < total_pages - 1)
195
+
196
+ @pagination_box.show
197
+ end
198
+
145
199
  # ---------------------------
146
200
  # Rendering
147
201
  # ---------------------------
@@ -151,37 +205,35 @@ module MittensUi
151
205
  def setup_css
152
206
  css = Gtk::CssProvider.new
153
207
  css.load(data: <<~CSS)
154
- frame {
155
- border-radius: 0;
156
- box-shadow: none;
157
- border: none;
158
- }
159
-
160
- .table-cell {
208
+ box.table-cell {
161
209
  padding: 6px 10px;
162
210
  border-bottom: 1px solid @borders;
211
+ background-color: @theme_base_color;
212
+ color: @theme_text_color;
163
213
  }
164
214
 
165
- .header-cell {
215
+ box.header-cell {
166
216
  font-weight: bold;
167
217
  padding: 8px 10px;
168
218
  border-bottom: 2px solid @borders;
169
- background-color: @theme_base_color;
219
+ background-color: shade(@theme_base_color, 0.95);
220
+ color: @theme_text_color;
170
221
  }
171
222
 
172
- .row-even {
173
- background-color: alpha(@theme_bg_color, 0.96);
223
+ box.row-even {
224
+ background-color: shade(@theme_base_color, 1.00);
174
225
  }
175
226
 
176
- .row-odd {
177
- background-color: alpha(@theme_bg_color, 0.90);
227
+ box.row-odd {
228
+ background-color: shade(@theme_base_color, 0.97);
178
229
  }
179
230
 
180
- .table-cell:hover {
181
- background-color: alpha(@theme_selected_bg_color, 0.25);
231
+ box.table-cell:hover {
232
+ background-color: @theme_selected_bg_color;
233
+ color: @theme_selected_fg_color;
182
234
  }
183
235
 
184
- .row-selected {
236
+ box.row-selected {
185
237
  background-color: @theme_selected_bg_color;
186
238
  color: @theme_selected_fg_color;
187
239
  }
@@ -190,7 +242,7 @@ module MittensUi
190
242
  Gtk::StyleContext.add_provider_for_display(
191
243
  Gdk::Display.default,
192
244
  css,
193
- Gtk::StyleProvider::PRIORITY_USER
245
+ Gtk::StyleProvider::PRIORITY_APPLICATION
194
246
  )
195
247
  end
196
248
 
@@ -202,19 +254,15 @@ module MittensUi
202
254
 
203
255
  @header_labels[col_idx] = label
204
256
 
205
- frame = Gtk::Frame.new
206
- frame.set_child(label)
207
- frame.set_hexpand(true)
208
- frame.style_context.add_class('header-cell')
257
+ box = Gtk::Box.new(:horizontal, 0)
258
+ box.append(label)
259
+ box.style_context.add_class('header-cell')
209
260
 
210
261
  gesture = Gtk::GestureClick.new
211
- gesture.set_button(0)
212
- gesture.signal_connect("pressed") do |_g, _n, _x, _y|
213
- sort_column(col_idx)
214
- end
215
- frame.add_controller(gesture)
262
+ gesture.signal_connect('pressed') { sort_column(col_idx) }
263
+ box.add_controller(gesture)
216
264
 
217
- @grid.attach(frame, col_idx, 0, 1, 1)
265
+ @grid.attach(box, col_idx, 0, 1, 1)
218
266
  end
219
267
  end
220
268
 
@@ -222,8 +270,11 @@ module MittensUi
222
270
  @row_widgets.each { |row| row.each { |w| @grid.remove(w) } }
223
271
  @row_widgets.clear
224
272
 
225
- @data.each_with_index do |row, row_idx|
226
- base_class = row_idx.even? ? 'row-even' : 'row-odd'
273
+ rows = paginated_data
274
+
275
+ rows.each_with_index do |row, visible_idx|
276
+ actual_idx = visible_idx + (@current_page * PAGE_SIZE)
277
+ base_class = visible_idx.even? ? 'row-even' : 'row-odd'
227
278
  widget_row = []
228
279
 
229
280
  row.each_with_index do |cell, col_idx|
@@ -231,46 +282,17 @@ module MittensUi
231
282
  label.set_xalign(0.0)
232
283
  label.set_hexpand(true)
233
284
 
234
- frame = Gtk::Frame.new
235
- frame.set_child(label)
236
- frame.set_hexpand(true)
237
- frame.style_context.add_class('table-cell')
238
- frame.style_context.add_class(base_class)
239
- frame.style_context.add_class('row-selected') if row_selected?(row_idx)
240
-
241
- gesture = Gtk::GestureClick.new
242
- gesture.set_button(0)
243
-
244
- gesture.signal_connect("pressed") do |_g, _n, _x, _y|
245
- now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
246
-
247
- if @last_click_time &&
248
- @last_clicked_row == row_idx &&
249
- (now - @last_click_time) < 0.3
250
-
251
- # DOUBLE CLICK
252
- @on_row_double_clicked&.call(@data[row_idx])
253
- else
254
- # SINGLE CLICK (delayed slightly to avoid conflict)
255
- GLib::Timeout.add(250) do
256
- if @last_clicked_row == row_idx
257
- @on_row_clicked&.call(@data[row_idx])
258
- end
259
- false
260
- end
261
- end
262
-
263
- @last_click_time = now
264
- @last_clicked_row = row_idx
265
- @selected_row_idx = row_idx
266
-
267
- render_rows
268
- end
285
+ box = Gtk::Box.new(:horizontal, 0)
286
+ box.append(label)
269
287
 
270
- frame.add_controller(gesture)
288
+ box.style_context.add_class('table-cell')
289
+ box.style_context.add_class(base_class)
290
+ box.style_context.add_class('row-selected') if row_selected?(actual_idx)
271
291
 
272
- @grid.attach(frame, col_idx, row_idx + 1, 1, 1)
273
- widget_row << frame
292
+ attach_click_handlers(box, actual_idx)
293
+
294
+ @grid.attach(box, col_idx, visible_idx + 1, 1, 1)
295
+ widget_row << box
274
296
  end
275
297
 
276
298
  @row_widgets << widget_row
@@ -280,21 +302,91 @@ module MittensUi
280
302
  @row_widgets.flatten.each(&:show)
281
303
  end
282
304
 
305
+ def attach_click_handlers(widget, row_idx)
306
+ gesture = Gtk::GestureClick.new
307
+ gesture.set_button(0)
308
+
309
+ gesture.signal_connect('pressed') do |_g, _n, _x, _y|
310
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
311
+
312
+ if @last_click_time &&
313
+ @last_clicked_row == row_idx &&
314
+ (now - @last_click_time) < 0.3
315
+
316
+ @on_row_double_clicked&.call(@data[row_idx])
317
+ else
318
+ GLib::Timeout.add(200) do
319
+ @last_clicked_row == row_idx ? @on_row_clicked&.call(@data[row_idx]) : false
320
+ end
321
+ end
322
+
323
+ @last_click_time = now
324
+ @last_clicked_row = row_idx
325
+ @selected_row_idx = row_idx
326
+
327
+ render_rows
328
+ end
329
+
330
+ widget.add_controller(gesture)
331
+ end
332
+
283
333
  def sort_column(col_idx)
284
334
  dir = @sort_directions[col_idx] ? :desc : :asc
285
335
  @sort_directions[col_idx] = !@sort_directions[col_idx]
286
336
 
287
337
  @header_labels.each_with_index do |lbl, i|
288
- lbl.set_label(@headers[i].to_s) if lbl
338
+ lbl.set_label(@headers[i])
289
339
  end
290
340
 
291
- arrow = dir == :asc ? " " : " "
341
+ arrow = dir == :asc ? ' ' : ' '
292
342
  @header_labels[col_idx].set_label(@headers[col_idx] + arrow)
293
343
 
294
- @data.sort_by! { |row| row[col_idx].to_s }
344
+ @data.sort_by! do |row|
345
+ val = row[col_idx]
346
+
347
+ case val
348
+ when Integer
349
+ val
350
+ when Float
351
+ val
352
+ when String
353
+ # try numeric string first
354
+ Integer(val) rescue Float(val) rescue val.downcase
355
+ else
356
+ val.to_s
357
+ end
358
+ end
359
+
295
360
  @data.reverse! if dir == :desc
296
361
 
362
+ render_rows
363
+ update_pagination_ui
364
+ end
365
+
366
+ def setup_keyboard
367
+ controller = Gtk::EventControllerKey.new
368
+
369
+ controller.signal_connect('key-pressed') do |_ctrl, key, _, _|
370
+ case key
371
+ when Gdk::Keyval::KEY_Up
372
+ move_selection(-1)
373
+ when Gdk::Keyval::KEY_Down
374
+ move_selection(1)
375
+ when Gdk::Keyval::KEY_Return
376
+ @on_row_double_clicked&.call(selected_row)
377
+ end
378
+ end
379
+
380
+ @scroller.add_controller(controller)
381
+ end
382
+
383
+ def move_selection(delta)
384
+ return if @data.empty?
385
+
386
+ @selected_row_idx ||= 0
387
+ @selected_row_idx = [[@selected_row_idx + delta, 0].max, @data.size - 1].min
388
+
297
389
  render_rows
298
390
  end
299
391
  end
300
- end
392
+ end
@@ -69,6 +69,15 @@ module MittensUi
69
69
  end
70
70
  end
71
71
 
72
+ # @param value [String] A String value that the label gets set to
73
+ def text=(value)
74
+ if @multiline
75
+ @text_buffer.text = value.to_s
76
+ else
77
+ @textbox.text = value.to_s
78
+ end
79
+ end
80
+
72
81
  # Clears all text from the widget.
73
82
  # Works in both single-line and multiline mode.
74
83
  #
@@ -1,3 +1,3 @@
1
1
  module MittensUi
2
- VERSION = "0.0.14"
2
+ VERSION = "0.0.16"
3
3
  end
@@ -43,5 +43,13 @@ module MittensUi
43
43
  @web_link = Gtk::LinkButton.new(@url, @name)
44
44
  super(@web_link, options)
45
45
  end
46
+
47
+ # Opens the set @url link in a web browser.
48
+ #
49
+ # @return [void]
50
+ def open_url
51
+ launcher = Gtk::UriLauncher.new(@url)
52
+ launcher.launch
53
+ end
46
54
  end
47
55
  end
data/mittens_ui.gemspec CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
14
14
  #spec.metadata["allowed_push_host"] = "TODO: Set to 'http://mygemserver.com'"
15
15
 
16
16
  spec.metadata["homepage_uri"] = spec.homepage
17
- spec.metadata["source_code_uri"] = "https://github.com/tuttza/mittens_ui"
17
+ spec.metadata["source_code_uri"] = "https://codeberg.org/tuttza/mittens_ui"
18
18
  #spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here."
19
19
 
20
20
  # Specify which files should be added to the gem when it is released.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mittens_ui
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.14
4
+ version: 0.0.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zach Tuttle
@@ -112,6 +112,8 @@ files:
112
112
  - examples/assets/gnome_logo.png
113
113
  - examples/assets/mittens_ui_preview.gif
114
114
  - examples/contacts.rb
115
+ - examples/hn.rb
116
+ - examples/keyboard_shortcut_demo.rb
115
117
  - examples/mig.rb
116
118
  - lib/mittens_ui.rb
117
119
  - lib/mittens_ui/alert.rb
@@ -150,7 +152,7 @@ licenses:
150
152
  - MIT
151
153
  metadata:
152
154
  homepage_uri: https://github.com/tuttza/mittens_ui
153
- source_code_uri: https://github.com/tuttza/mittens_ui
155
+ source_code_uri: https://codeberg.org/tuttza/mittens_ui
154
156
  rdoc_options: []
155
157
  require_paths:
156
158
  - lib
@@ -165,7 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
165
167
  - !ruby/object:Gem::Version
166
168
  version: '0'
167
169
  requirements: []
168
- rubygems_version: 4.0.3
170
+ rubygems_version: 4.0.6
169
171
  specification_version: 4
170
172
  summary: A tiny GUI toolkit written on top of GTK
171
173
  test_files: []