bubbles 0.0.5 → 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.
Files changed (58) hide show
  1. checksums.yaml +5 -5
  2. data/LICENSE.txt +21 -0
  3. data/README.md +524 -80
  4. data/bubbles.gemspec +29 -21
  5. data/lib/bubbles/cursor.rb +169 -0
  6. data/lib/bubbles/file_picker.rb +397 -0
  7. data/lib/bubbles/help.rb +170 -0
  8. data/lib/bubbles/key.rb +96 -0
  9. data/lib/bubbles/list.rb +365 -0
  10. data/lib/bubbles/paginator.rb +158 -0
  11. data/lib/bubbles/progress.rb +276 -0
  12. data/lib/bubbles/spinner/spinners.rb +77 -0
  13. data/lib/bubbles/spinner.rb +122 -0
  14. data/lib/bubbles/stopwatch.rb +189 -0
  15. data/lib/bubbles/table.rb +248 -0
  16. data/lib/bubbles/text_area.rb +503 -0
  17. data/lib/bubbles/text_input.rb +543 -0
  18. data/lib/bubbles/timer.rb +196 -0
  19. data/lib/bubbles/version.rb +4 -1
  20. data/lib/bubbles/viewport.rb +296 -0
  21. data/lib/bubbles.rb +18 -35
  22. data/sig/bubbles/cursor.rbs +87 -0
  23. data/sig/bubbles/file_picker.rbs +138 -0
  24. data/sig/bubbles/help.rbs +88 -0
  25. data/sig/bubbles/key.rbs +63 -0
  26. data/sig/bubbles/list.rbs +138 -0
  27. data/sig/bubbles/paginator.rbs +90 -0
  28. data/sig/bubbles/progress.rbs +123 -0
  29. data/sig/bubbles/spinner/spinners.rbs +32 -0
  30. data/sig/bubbles/spinner.rbs +74 -0
  31. data/sig/bubbles/stopwatch.rbs +97 -0
  32. data/sig/bubbles/table.rbs +119 -0
  33. data/sig/bubbles/text_area.rbs +161 -0
  34. data/sig/bubbles/text_input.rbs +183 -0
  35. data/sig/bubbles/timer.rbs +107 -0
  36. data/sig/bubbles/version.rbs +5 -0
  37. data/sig/bubbles/viewport.rbs +125 -0
  38. data/sig/bubbles.rbs +4 -0
  39. metadata +66 -67
  40. data/.gitignore +0 -14
  41. data/.rspec +0 -2
  42. data/.travis.yml +0 -10
  43. data/Gemfile +0 -4
  44. data/LICENSE +0 -20
  45. data/Rakefile +0 -6
  46. data/bin/console +0 -14
  47. data/bin/setup +0 -8
  48. data/exe/bubbles +0 -5
  49. data/lib/bubbles/bubblicious_file.rb +0 -42
  50. data/lib/bubbles/command_queue.rb +0 -43
  51. data/lib/bubbles/common_uploader_interface.rb +0 -13
  52. data/lib/bubbles/config.rb +0 -149
  53. data/lib/bubbles/dir_watcher.rb +0 -53
  54. data/lib/bubbles/uploaders/local_dir.rb +0 -39
  55. data/lib/bubbles/uploaders/s3.rb +0 -36
  56. data/lib/bubbles/uploaders/s3_ensure_connection.rb +0 -26
  57. data/tmp/dummy_local_dir_uploader_dir/.gitkeep +0 -0
  58. data/tmp/dummy_processing_dir/.gitkeep +0 -0
data/bubbles.gemspec CHANGED
@@ -1,28 +1,36 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
- require 'bubbles/version'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bubbles/version"
5
4
 
6
5
  Gem::Specification.new do |spec|
7
- spec.name = "bubbles"
8
- spec.version = Bubbles::VERSION
9
- spec.authors = ["Tomas Valent"]
10
- spec.licenses = ['MIT']
11
- spec.email = ["equivalent@eq8.eu"]
6
+ spec.name = "bubbles"
7
+ spec.version = Bubbles::VERSION
8
+ spec.authors = ["Marco Roth"]
9
+ spec.email = ["marco.roth@intergga.ch"]
10
+
11
+ spec.summary = "TUI components for Bubble Tea."
12
+ spec.description = "Ruby port of Charm's Bubbles. Common UI components for building terminal applications with Bubble Tea."
13
+ spec.homepage = "https://github.com/marcoroth/bubbles-ruby"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.2.0"
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/marcoroth/bubbles-ruby"
19
+ spec.metadata["changelog_uri"] = "https://github.com/marcoroth/bubbles-ruby/releases"
20
+ spec.metadata["rubygems_mfa_required"] = "true"
12
21
 
13
- spec.summary = %q{Lightweight daemon file uploader to cloud}
14
- spec.description = %q{Daemonized file uploader that watch a folder and uploads any files files to AWS S3. Designed for Raspberry pi zero}
15
- spec.homepage = "https://github.com/equivalent/bubbles"
22
+ spec.files = Dir[
23
+ "bubbles.gemspec",
24
+ "LICENSE.txt",
25
+ "README.md",
26
+ "{lib,sig}/**/*"
27
+ ]
16
28
 
17
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
18
- f.match(%r{^(test|spec|features)/})
19
- end
20
- spec.bindir = "exe"
21
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
29
+ spec.bindir = "exe"
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
22
31
  spec.require_paths = ["lib"]
23
32
 
24
- spec.add_dependency "aws-sdk", "~> 2"
25
- spec.add_development_dependency "bundler", "~> 1.14"
26
- spec.add_development_dependency "rake", "~> 10.0"
27
- spec.add_development_dependency "rspec", "~> 3.0"
33
+ spec.add_dependency "bubbletea"
34
+ spec.add_dependency "harmonica"
35
+ spec.add_dependency "lipgloss"
28
36
  end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # Cursor provides cursor functionality for text input components.
6
+ #
7
+ # Example:
8
+ # cursor = Bubbles::Cursor.new
9
+ # cursor.char = "a"
10
+ # cursor.focus
11
+ #
12
+ # # In update:
13
+ # cursor, command = cursor.update(message)
14
+ #
15
+ # # In view:
16
+ # cursor.view
17
+ #
18
+ class Cursor
19
+ MODE_BLINK = :blink #: Symbol
20
+ MODE_STATIC = :static #: Symbol
21
+ MODE_HIDE = :hide #: Symbol
22
+
23
+ DEFAULT_BLINK_SPEED = 0.53 #: Float
24
+
25
+ class InitialBlinkMessage < Bubbletea::Message
26
+ end
27
+
28
+ class BlinkMessage < Bubbletea::Message
29
+ attr_reader :id #: Integer
30
+ attr_reader :tag #: Integer
31
+
32
+ #: (id: Integer, tag: Integer) -> void
33
+ def initialize(id:, tag:)
34
+ super()
35
+
36
+ @id = id
37
+ @tag = tag
38
+ end
39
+ end
40
+
41
+ class BlinkCanceledMessage < Bubbletea::Message
42
+ end
43
+
44
+ # rubocop:disable Style/ClassVars
45
+ @@last_id = 0 #: Integer
46
+ @@id_mutex = Mutex.new #: Mutex
47
+
48
+ #: () -> Integer
49
+ def self.next_id
50
+ @@id_mutex.synchronize do
51
+ @@last_id += 1
52
+ end
53
+ end
54
+ # rubocop:enable Style/ClassVars
55
+
56
+ attr_reader :id #: Integer
57
+ attr_reader :mode #: Symbol
58
+ attr_accessor :char #: String
59
+
60
+ attr_accessor :blink_speed #: Float
61
+ attr_accessor :style #: Lipgloss::Style?
62
+ attr_accessor :text_style #: Lipgloss::Style?
63
+
64
+ #: () -> void
65
+ def initialize
66
+ @id = self.class.next_id
67
+ @blink_speed = DEFAULT_BLINK_SPEED
68
+ @style = nil
69
+ @text_style = nil
70
+ @char = ""
71
+ @focus = false
72
+ @blink = true
73
+ @blink_tag = 0
74
+ @mode = MODE_BLINK
75
+ end
76
+
77
+ #: () -> bool
78
+ def blink?
79
+ @blink
80
+ end
81
+
82
+ #: () -> bool
83
+ def focused?
84
+ @focus
85
+ end
86
+
87
+ #: (Bubbletea::Message) -> [Cursor, Bubbletea::Command?]
88
+ def update(message)
89
+ case message
90
+ when InitialBlinkMessage
91
+ return [self, nil] if @mode != MODE_BLINK || !@focus
92
+
93
+ [self, blink_command]
94
+ when BlinkMessage
95
+ return [self, nil] if @mode != MODE_BLINK || !@focus
96
+ return [self, nil] if message.id != @id || message.tag != @blink_tag
97
+
98
+ @blink = !@blink
99
+
100
+ [self, blink_command]
101
+ when BlinkCanceledMessage
102
+ [self, nil]
103
+ else # rubocop:disable Lint/DuplicateBranch
104
+ [self, nil]
105
+ end
106
+ end
107
+
108
+ #: (Symbol) -> Bubbletea::Command?
109
+ def set_mode(mode) # rubocop:disable Naming/AccessorMethodName
110
+ return nil unless [MODE_BLINK, MODE_STATIC, MODE_HIDE].include?(mode)
111
+
112
+ @mode = mode
113
+ @blink = @mode == MODE_HIDE || !@focus
114
+
115
+ mode == MODE_BLINK ? self.class.blink : nil
116
+ end
117
+
118
+ #: () -> Bubbletea::Command?
119
+ def focus
120
+ @focus = true
121
+ @blink = @mode == MODE_HIDE
122
+
123
+ return unless @mode == MODE_BLINK && @focus
124
+
125
+ blink_command
126
+ end
127
+
128
+ #: () -> void
129
+ def blur
130
+ @focus = false
131
+ @blink = true
132
+ end
133
+
134
+ #: () -> String
135
+ def view
136
+ if @blink
137
+ if (text_style = @text_style)
138
+ text_style.render(@char)
139
+ else
140
+ @char
141
+ end
142
+ elsif (style = @style)
143
+ style.reverse(true).render(@char)
144
+ else
145
+ "\e[7m#{@char}\e[0m"
146
+ end
147
+ end
148
+
149
+ #: () -> Bubbletea::SendMessage
150
+ def self.blink
151
+ Bubbletea.send_message(InitialBlinkMessage.new)
152
+ end
153
+
154
+ private
155
+
156
+ #: () -> Bubbletea::Command?
157
+ def blink_command
158
+ return nil unless @mode == MODE_BLINK
159
+
160
+ @blink_tag += 1
161
+ current_id = @id
162
+ current_tag = @blink_tag
163
+
164
+ Bubbletea.tick(@blink_speed) do
165
+ BlinkMessage.new(id: current_id, tag: current_tag)
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module Bubbles
5
+ # FilePicker is a component for browsing and selecting files.
6
+ #
7
+ # Example:
8
+ # picker = Bubbles::FilePicker.new
9
+ # picker.current_directory = "."
10
+ #
11
+ # # In update:
12
+ # picker, command = picker.update(message)
13
+ #
14
+ # # Check if a file was selected:
15
+ # if picker.did_select_file?
16
+ # selected_file = picker.path
17
+ # end
18
+ #
19
+ # # In view:
20
+ # picker.view
21
+ #
22
+ class FilePicker
23
+ class ReadDirMessage < Bubbletea::Message
24
+ attr_reader :id #: Integer
25
+ attr_reader :entries #: Array[Hash[Symbol, untyped]]
26
+
27
+ #: (id: Integer, entries: Array[Hash[Symbol, untyped]]) -> void
28
+ def initialize(id:, entries:)
29
+ super()
30
+ @id = id
31
+ @entries = entries
32
+ end
33
+ end
34
+
35
+ class ErrorMessage < Bubbletea::Message
36
+ attr_reader :error #: StandardError
37
+
38
+ #: (StandardError) -> void
39
+ def initialize(error)
40
+ super()
41
+ @error = error
42
+ end
43
+ end
44
+
45
+ # rubocop:disable Style/ClassVars
46
+ @@last_id = 0 # @rbs skip
47
+ @@id_mutex = Mutex.new # @rbs skip
48
+
49
+ #: () -> Integer
50
+ def self.next_id
51
+ @@id_mutex.synchronize do
52
+ @@last_id += 1
53
+ end
54
+ end
55
+ # rubocop:enable Style/ClassVars
56
+
57
+ attr_accessor :height #: Integer
58
+ attr_accessor :cursor_char #: String
59
+ attr_accessor :show_permissions #: bool
60
+ attr_accessor :show_size #: bool
61
+ attr_accessor :show_hidden #: bool
62
+ attr_accessor :dir_allowed #: bool
63
+ attr_accessor :file_allowed #: bool
64
+ attr_accessor :allowed_types #: Array[String]
65
+ attr_accessor :cursor_style #: Lipgloss::Style?
66
+ attr_accessor :dir_style #: Lipgloss::Style?
67
+ attr_accessor :file_style #: Lipgloss::Style?
68
+ attr_accessor :selected_style #: Lipgloss::Style?
69
+ attr_accessor :permission_style #: Lipgloss::Style?
70
+ attr_accessor :size_style #: Lipgloss::Style?
71
+ attr_accessor :disabled_style #: Lipgloss::Style?
72
+
73
+ attr_reader :id #: Integer
74
+ attr_reader :current_directory #: String
75
+ attr_reader :path #: String?
76
+ attr_reader :files #: Array[Hash[Symbol, untyped]]
77
+
78
+ # @rbs directory: String -- Starting directory
79
+ # @rbs return: void
80
+ def initialize(directory: ".")
81
+ @id = self.class.next_id
82
+ @current_directory = File.expand_path(directory)
83
+ @cursor_char = ">"
84
+ @path = nil
85
+ @selected = 0
86
+ @offset = 0
87
+ @height = 10
88
+
89
+ @show_permissions = true
90
+ @show_size = true
91
+ @show_hidden = false
92
+ @dir_allowed = false
93
+ @file_allowed = true
94
+ @allowed_types = [] #: Array[String]
95
+
96
+ @files = [] #: Array[Hash[Symbol, untyped]]
97
+ @did_select = false
98
+ @directory_stack = [] #: Array[Hash[Symbol, untyped]]
99
+
100
+ @cursor_style = nil
101
+ @dir_style = nil
102
+ @file_style = nil
103
+ @selected_style = nil
104
+ @permission_style = nil
105
+ @size_style = nil
106
+ @disabled_style = nil
107
+
108
+ read_directory
109
+ end
110
+
111
+ #: (String) -> void
112
+ def current_directory=(dir)
113
+ @current_directory = File.expand_path(dir)
114
+ read_directory
115
+ end
116
+
117
+ #: () -> bool
118
+ def did_select_file?
119
+ @did_select
120
+ end
121
+
122
+ #: () -> bool
123
+ def selected?
124
+ !@path.nil?
125
+ end
126
+
127
+ #: () -> void
128
+ def clear_selected
129
+ @path = nil
130
+ @did_select = false
131
+ end
132
+
133
+ #: () -> Integer
134
+ def cursor
135
+ @selected
136
+ end
137
+
138
+ #: (Bubbletea::Message) -> [FilePicker, Bubbletea::Command?]
139
+ def update(message)
140
+ @did_select = false
141
+
142
+ case message
143
+ when ReadDirMessage
144
+ return [self, nil] if message.id != @id
145
+
146
+ @files = message.entries
147
+ @selected = @selected.clamp(0, [@files.length - 1, 0].max)
148
+ update_offset
149
+
150
+ when ErrorMessage
151
+ # Handle error
152
+ when Bubbletea::KeyMessage
153
+ handle_key(message)
154
+ end
155
+
156
+ [self, nil]
157
+ end
158
+
159
+ #: () -> String
160
+ def view
161
+ lines = [] #: Array[String]
162
+
163
+ lines << render_path(@current_directory)
164
+ lines << ""
165
+
166
+ if @files.empty?
167
+ lines << " No files found"
168
+ else
169
+ visible_end = [@offset + @height, @files.length].min
170
+ (@offset...visible_end).each do |i|
171
+ lines << render_entry(@files[i], i == @selected)
172
+ end
173
+ end
174
+
175
+ lines << "" while lines.length < @height + 2
176
+
177
+ lines.join("\n")
178
+ end
179
+
180
+ private
181
+
182
+ #: (Bubbletea::KeyMessage) -> void
183
+ def handle_key(message)
184
+ case message.to_s
185
+ when "up", "k", "ctrl+p"
186
+ @selected = [@selected - 1, 0].max
187
+ update_offset
188
+ when "down", "j", "ctrl+n"
189
+ @selected = [@selected + 1, @files.length - 1].min
190
+ update_offset
191
+ when "pgup", "K"
192
+ @selected = [@selected - @height, 0].max
193
+ update_offset
194
+ when "pgdown", "J"
195
+ @selected = [@selected + @height, @files.length - 1].min
196
+ update_offset
197
+ when "home", "g"
198
+ @selected = 0
199
+ update_offset
200
+ when "end", "G"
201
+ @selected = @files.length - 1
202
+ update_offset
203
+ when "left", "h", "backspace"
204
+ go_up
205
+ when "right", "l", "enter"
206
+ entry = @files[@selected]
207
+
208
+ if entry
209
+ if entry[:directory]
210
+ enter_directory(entry)
211
+ elsif can_select?(entry)
212
+ select_file(entry)
213
+ end
214
+ end
215
+ end
216
+ end
217
+
218
+ #: () -> void
219
+ def read_directory
220
+ entries = [] #: Array[Hash[Symbol, untyped]]
221
+
222
+ begin
223
+ Dir.foreach(@current_directory) do |name|
224
+ next if name == "."
225
+ next if name == ".." && @directory_stack.empty?
226
+ next if !@show_hidden && name.start_with?(".") && name != ".."
227
+
228
+ full_path = File.join(@current_directory, name)
229
+
230
+ begin
231
+ stat = File.stat(full_path)
232
+ is_dir = stat.directory?
233
+ is_symlink = File.symlink?(full_path)
234
+
235
+ entries << {
236
+ name: name,
237
+ path: full_path,
238
+ directory: is_dir,
239
+ symlink: is_symlink,
240
+ size: is_dir ? 0 : stat.size,
241
+ permissions: format_permissions(stat.mode),
242
+ extension: is_dir ? "" : File.extname(name).delete("."),
243
+ }
244
+ rescue SystemCallError
245
+ # Skip files we can't stat
246
+ end
247
+ end
248
+ rescue SystemCallError
249
+ @files = []
250
+ return
251
+ end
252
+
253
+ entries.sort_by! do |entry|
254
+ [entry[:name] == ".." ? 0 : 1, entry[:directory] ? 0 : 1, entry[:name].downcase]
255
+ end
256
+
257
+ @files = entries
258
+ @selected = @selected.clamp(0, [@files.length - 1, 0].max)
259
+
260
+ update_offset
261
+ end
262
+
263
+ #: (Hash[Symbol, untyped]) -> void
264
+ def enter_directory(entry)
265
+ @directory_stack.push({ directory: @current_directory, selected: @selected, offset: @offset })
266
+ @current_directory = File.expand_path(entry[:path])
267
+ @selected = 0
268
+ @offset = 0
269
+
270
+ read_directory
271
+ end
272
+
273
+ #: () -> void
274
+ def go_up
275
+ return if @directory_stack.empty?
276
+
277
+ state = @directory_stack.pop
278
+ @current_directory = state[:directory]
279
+ @selected = state[:selected]
280
+ @offset = state[:offset]
281
+
282
+ read_directory
283
+ end
284
+
285
+ #: (Hash[Symbol, untyped]) -> void
286
+ def select_file(entry)
287
+ @path = entry[:path]
288
+ @did_select = true
289
+ end
290
+
291
+ #: (Hash[Symbol, untyped]) -> bool
292
+ def can_select?(entry)
293
+ return @dir_allowed if entry[:directory]
294
+ return false unless @file_allowed
295
+
296
+ @allowed_types.empty? || @allowed_types.include?(entry[:extension])
297
+ end
298
+
299
+ #: (String) -> String
300
+ def render_path(path)
301
+ home = Dir.home
302
+ display = path.start_with?(home) ? path.sub(home, "~") : path
303
+ (style = @dir_style) ? style.render(display) : "\e[34m#{display}\e[0m"
304
+ end
305
+
306
+ #: (Hash[Symbol, untyped], bool) -> String
307
+ def render_entry(entry, selected)
308
+ cursor = selected ? "#{@cursor_char} " : " "
309
+
310
+ if (style = @cursor_style)
311
+ cursor = style.render(cursor)
312
+ end
313
+
314
+ name = entry[:name]
315
+ is_disabled = !can_select?(entry) && !entry[:directory]
316
+
317
+ name_rendered = if entry[:directory]
318
+ if entry[:name] == ".."
319
+ ".."
320
+ else
321
+ (style = @dir_style) ? style.render("#{name}/") : "\e[34m#{name}/\e[0m"
322
+ end
323
+ elsif is_disabled
324
+ (style = @disabled_style) ? style.render(name) : "\e[90m#{name}\e[0m"
325
+ else
326
+ (style = @file_style) ? style.render(name) : name
327
+ end
328
+
329
+ if selected && !is_disabled
330
+ name_rendered = (style = @selected_style) ? style.render(name) : "\e[1m#{name}\e[0m"
331
+ name_rendered += "/" if entry[:directory] && entry[:name] != ".."
332
+ end
333
+
334
+ parts = [cursor, name_rendered]
335
+
336
+ if @show_size && !entry[:directory]
337
+ size = format_size(entry[:size])
338
+ size_rendered = (style = @size_style) ? style.render(size) : "\e[90m#{size}\e[0m"
339
+
340
+ parts << size_rendered
341
+ end
342
+
343
+ if @show_permissions
344
+ perms = entry[:permissions]
345
+ perms_rendered = (style = @permission_style) ? style.render(perms) : "\e[90m#{perms}\e[0m"
346
+ parts << perms_rendered
347
+ end
348
+
349
+ parts.join(" ")
350
+ end
351
+
352
+ #: (Integer) -> String
353
+ def format_permissions(mode)
354
+ permissions = ""
355
+
356
+ permissions += mode.nobits?(0o400) ? "-" : "r"
357
+ permissions += mode.nobits?(0o200) ? "-" : "w"
358
+ permissions += mode.nobits?(0o100) ? "-" : "x"
359
+ permissions += mode.nobits?(0o040) ? "-" : "r"
360
+ permissions += mode.nobits?(0o020) ? "-" : "w"
361
+ permissions += mode.nobits?(0o010) ? "-" : "x"
362
+ permissions += mode.nobits?(0o004) ? "-" : "r"
363
+ permissions += mode.nobits?(0o002) ? "-" : "w"
364
+ permissions += mode.nobits?(0o001) ? "-" : "x"
365
+
366
+ permissions
367
+ end
368
+
369
+ #: (Integer) -> String
370
+ def format_size(bytes)
371
+ return "0 B" if bytes.zero?
372
+
373
+ units = ["B", "K", "M", "G", "T"]
374
+ exp = (Math.log(bytes) / Math.log(1024)).to_i
375
+ exp = [exp, units.length - 1].min
376
+
377
+ size = bytes.to_f / (1024**exp)
378
+
379
+ if size >= 100
380
+ format("%<size>.0f%<unit>s", size: size, unit: units[exp])
381
+ else
382
+ format("%<size>.1f%<unit>s", size: size, unit: units[exp])
383
+ end
384
+ end
385
+
386
+ #: () -> void
387
+ def update_offset
388
+ if @selected < @offset
389
+ @offset = @selected
390
+ elsif @selected >= @offset + @height
391
+ @offset = @selected - @height + 1
392
+ end
393
+
394
+ @offset = @offset.clamp(0, [0, @files.length - @height].max)
395
+ end
396
+ end
397
+ end