ektoplayer 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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +49 -0
  3. data/bin/ektoplayer +7 -0
  4. data/lib/ektoplayer.rb +10 -0
  5. data/lib/ektoplayer/application.rb +148 -0
  6. data/lib/ektoplayer/bindings.rb +230 -0
  7. data/lib/ektoplayer/browsepage.rb +138 -0
  8. data/lib/ektoplayer/client.rb +18 -0
  9. data/lib/ektoplayer/common.rb +91 -0
  10. data/lib/ektoplayer/config.rb +247 -0
  11. data/lib/ektoplayer/controllers/browser.rb +47 -0
  12. data/lib/ektoplayer/controllers/controller.rb +9 -0
  13. data/lib/ektoplayer/controllers/help.rb +21 -0
  14. data/lib/ektoplayer/controllers/info.rb +22 -0
  15. data/lib/ektoplayer/controllers/mainwindow.rb +40 -0
  16. data/lib/ektoplayer/controllers/playlist.rb +60 -0
  17. data/lib/ektoplayer/database.rb +199 -0
  18. data/lib/ektoplayer/events.rb +56 -0
  19. data/lib/ektoplayer/models/browser.rb +127 -0
  20. data/lib/ektoplayer/models/database.rb +49 -0
  21. data/lib/ektoplayer/models/model.rb +15 -0
  22. data/lib/ektoplayer/models/player.rb +28 -0
  23. data/lib/ektoplayer/models/playlist.rb +72 -0
  24. data/lib/ektoplayer/models/search.rb +42 -0
  25. data/lib/ektoplayer/models/trackloader.rb +17 -0
  26. data/lib/ektoplayer/mp3player.rb +151 -0
  27. data/lib/ektoplayer/operations/browser.rb +19 -0
  28. data/lib/ektoplayer/operations/operations.rb +26 -0
  29. data/lib/ektoplayer/operations/player.rb +11 -0
  30. data/lib/ektoplayer/operations/playlist.rb +67 -0
  31. data/lib/ektoplayer/theme.rb +102 -0
  32. data/lib/ektoplayer/trackloader.rb +146 -0
  33. data/lib/ektoplayer/ui.rb +404 -0
  34. data/lib/ektoplayer/ui/colors.rb +105 -0
  35. data/lib/ektoplayer/ui/widgets.rb +195 -0
  36. data/lib/ektoplayer/ui/widgets/container.rb +125 -0
  37. data/lib/ektoplayer/ui/widgets/labelwidget.rb +43 -0
  38. data/lib/ektoplayer/ui/widgets/listwidget.rb +332 -0
  39. data/lib/ektoplayer/ui/widgets/tabbedcontainer.rb +110 -0
  40. data/lib/ektoplayer/updater.rb +77 -0
  41. data/lib/ektoplayer/views/browser.rb +25 -0
  42. data/lib/ektoplayer/views/help.rb +46 -0
  43. data/lib/ektoplayer/views/info.rb +208 -0
  44. data/lib/ektoplayer/views/mainwindow.rb +64 -0
  45. data/lib/ektoplayer/views/playinginfo.rb +135 -0
  46. data/lib/ektoplayer/views/playlist.rb +39 -0
  47. data/lib/ektoplayer/views/progressbar.rb +51 -0
  48. data/lib/ektoplayer/views/splash.rb +99 -0
  49. data/lib/ektoplayer/views/trackrenderer.rb +137 -0
  50. data/lib/ektoplayer/views/volumemeter.rb +74 -0
  51. metadata +164 -0
@@ -0,0 +1,19 @@
1
+ module Ektoplayer
2
+ module Operations
3
+ class Browser
4
+ # Operations for +Model::Browser+:
5
+ # +enter+:: see
6
+ # +back+:: see
7
+ # +add_to_playlist+:: see
8
+ def initialize(operations, browser, playlist)
9
+ register = operations.with_register('browser.')
10
+ register.(:enter, &browser.method(:enter))
11
+ register.(:back, &browser.method(:back))
12
+ register.(:add_to_playlist) do |index|
13
+ tracks = browser.tracks(index)
14
+ playlist.add(*tracks)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ module Ektoplayer
2
+ module Operations
3
+ # Operations
4
+ # Since every operation is implemented excactly once
5
+ # we don't use an Event object.
6
+ #
7
+ # Since doing a "class.send()" is faster than "hash[command].call()"
8
+ # we implement the operations using an object instead of a hash.
9
+ #
10
+ class Operations
11
+ # Register a new command
12
+ def register(name, &block)
13
+ self.define_singleton_method(name, &block)
14
+ end
15
+ alias :reg :register
16
+
17
+ # Helper for registering multiple commands
18
+ def with_register(prefix='', &block)
19
+ reg_func = proc { |name, &blk| register("#{prefix}#{name}", &blk) }
20
+ block.(reg_func) if block
21
+ reg_func
22
+ end
23
+ end
24
+ end
25
+ end
26
+
@@ -0,0 +1,11 @@
1
+ module Ektoplayer
2
+ module Operations
3
+ class Player
4
+ def initialize(operations, player)
5
+ register = operations.with_register('player.')
6
+ %w(play stop pause toggle forward backward).
7
+ each { |op| register.(op, &player.method(op)) }
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,67 @@
1
+ module Ektoplayer
2
+ module Operations
3
+ class Playlist
4
+ def initialize(operations, playlist, player, trackloader)
5
+ @playlist, @player, @trackloader = playlist, player, trackloader
6
+ register = operations.with_register('playlist.')
7
+
8
+ register.(:clear, &@playlist.method(:clear))
9
+ register.(:delete, &@playlist.method(:delete))
10
+
11
+ %w(play reload play_next play_prev download_album).each do |operation|
12
+ register.(operation, &self.method(operation))
13
+ end
14
+ end
15
+
16
+ def download_album(index)
17
+ return unless track = @playlist[index]
18
+ Thread.new do
19
+ @trackloader.download_album(track['url'])
20
+ end
21
+ end
22
+
23
+ def reload(index)
24
+ return unless track = @playlist[index]
25
+ Thread.new do
26
+ @trackloader.get_track_file(track['url'], reload: true)
27
+ end
28
+ end
29
+
30
+ def play(index)
31
+ return unless track = @playlist[index]
32
+ Thread.new do
33
+ @playlist.current_playing=(index)
34
+ @player.play(@trackloader.get_track_file(track['url']))
35
+ end
36
+ end
37
+
38
+ def play_next
39
+ return if @playlist.empty? or !@playlist.current_playing
40
+ index = (@playlist.current_playing + 1) % @playlist.size
41
+ play(index)
42
+ end
43
+
44
+ def play_prev
45
+ return if @playlist.empty? or !@playlist.current_playing
46
+
47
+ if @playlist.current_playing == 0
48
+ play(@playlist.size - 1)
49
+ else
50
+ play(@playlist.current_playing - 1)
51
+ end
52
+ end
53
+
54
+ private def get_next_pos
55
+ return unless index = @playlist.current_playing
56
+
57
+ if @playlist.repeat_mode == :track
58
+ return index
59
+ elsif index + 1 >= @playlist.size
60
+ return 0 if @playlist.repeat_mode == :playlist
61
+ else
62
+ return index + 1
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,102 @@
1
+ require_relative 'ui/colors'
2
+
3
+ module Ektoplayer
4
+ class Theme
5
+ attr_reader :current
6
+
7
+ def initialize
8
+ @current = 0
9
+ @theme = {
10
+ 0 => { default: [-1, -1].freeze,
11
+ 'url': [-1, -1, :underline ].freeze},
12
+ 8 => { default: [-1, -1].freeze,
13
+ 'url': [:magenta, -1, :underline].freeze,
14
+
15
+ 'info.head': [:blue, -1, :bold ].freeze,
16
+ 'info.tag': [:blue ].freeze,
17
+ 'info.value': [:magenta ].freeze,
18
+ 'info.description': [:blue ].freeze,
19
+ 'info.download.file': [:blue ].freeze,
20
+ 'info.download.percent': [:magenta, -1 ].freeze,
21
+ 'info.download.error': [:red ].freeze,
22
+
23
+ 'progressbar.progress': [:blue ].freeze,
24
+ 'progressbar.rest': [:black ].freeze,
25
+
26
+ 'volumemeter.level': [:magenta ].freeze,
27
+ 'volumemeter.rest': [:black ].freeze,
28
+
29
+ 'tabs': [:none ].freeze,
30
+ 'tab_selected': [:blue ].freeze,
31
+
32
+ 'list.item_even': [:blue ].freeze,
33
+ 'list.item_odd': [:blue ].freeze,
34
+
35
+ 'playinginfo.position': [:magenta ].freeze,
36
+ 'playinginfo.state': [:cyan ].freeze,
37
+
38
+ 'help.widget_name': [:blue, -1, :bold ].freeze,
39
+ 'help.key_name': [:blue ].freeze,
40
+ 'help.command_name': [:magenta ].freeze,
41
+ 'help.command_desc': [:yellow ].freeze},
42
+ 256 => { default: [-1, -1].freeze,
43
+ 'url': [97, -1, :underline ].freeze,
44
+
45
+ 'info.head': [32, -1, :bold ].freeze,
46
+ 'info.tag': [74 ].freeze,
47
+ 'info.value': [67 ].freeze,
48
+ 'info.description': [67 ].freeze,
49
+ 'info.download.file': [75 ].freeze,
50
+ 'info.download.percent': [68 ].freeze,
51
+ 'info.download.error': [:red ].freeze,
52
+
53
+ 'progressbar.progress': [23 ].freeze,
54
+ 'progressbar.rest': [236 ].freeze,
55
+
56
+ 'volumemeter.level': [:magenta ].freeze,
57
+ 'volumemeter.rest': [236 ].freeze,
58
+
59
+ 'tabs': [250 ].freeze,
60
+ 'tab_selected': [75 ].freeze,
61
+
62
+ 'list.item_even': [:blue ].freeze,
63
+ 'list.item_odd': [25 ].freeze,
64
+
65
+ 'help.widget_name': [33 ].freeze,
66
+ 'help.key_name': [75 ].freeze,
67
+ 'help.command_name': [68 ].freeze,
68
+ 'help.command_desc': [29 ].freeze}
69
+ }.freeze
70
+ end
71
+
72
+ def color(name, *defs, theme: 8)
73
+ @theme[theme][name.to_sym] = defs.freeze
74
+ end
75
+
76
+ def color_mono(*args) color(*args, theme: 0) end
77
+ def color_256(*args) color(*args, theme: 256) end
78
+
79
+ def get(theme_def) UI::Colors.get(theme_def) end
80
+ alias :[] :get
81
+
82
+ def use_colors(colors)
83
+ fail ArgumentError unless @theme[colors]
84
+ @current = colors
85
+
86
+ UI::Colors.reset
87
+ @theme.values.map(&:keys).flatten.each do |name|
88
+ defs ||= @theme[256][name] if @current == 256
89
+ defs ||= @theme[8][name] if @current >= 8
90
+ defs ||= @theme[0][name]
91
+
92
+ unless defs
93
+ defs ||= @theme[256][:default] if @current == 256
94
+ defs ||= @theme[8][:default] if @current >= 8
95
+ defs ||= @theme[0][:default]
96
+ end
97
+
98
+ UI::Colors.set(name, *defs)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,146 @@
1
+ require 'fileutils'
2
+ require 'net/https'
3
+ require 'uri'
4
+
5
+ require_relative 'events'
6
+ require_relative 'common'
7
+
8
+ module Ektoplayer
9
+ class Trackloader
10
+ attr_reader :downloads
11
+
12
+ def initialize(database)
13
+ @downloads = []
14
+ @database = database
15
+ end
16
+
17
+ def get_track_infos(url)
18
+ r = @database.select(filters: [{tag: :url, operator: :==, value: url}])[0]
19
+
20
+ r.update(
21
+ @database.get_archives(url).select {|_|_['archive_type'] == 'MP3'}[0]
22
+ )
23
+
24
+ r['archive_filename'] = URI.unescape(File.basename(URI.parse(r['archive_url']).path))
25
+ r['archive_basename'] = File.basename(r['archive_filename'], '.zip')
26
+ r['album_path'] = File.join(Config[:archive_dir], r['archive_basename'])
27
+ r
28
+ end
29
+
30
+ def download_album(url)
31
+ track_info = get_track_infos(url)
32
+ return if File.exists? track_info['album_path']
33
+
34
+ archive_file = File.join(Config[:download_dir], track_info['archive_filename'])
35
+ return if File.exists? archive_file
36
+
37
+ dl = DownloadThread.new(track_info['archive_url'], archive_file)
38
+
39
+ if Config[:auto_extract_to_archive_dir]
40
+ dl.events.on(:completed) do
41
+ begin
42
+ extract_dir = track_info['album_path']
43
+ FileUtils::mkdir_p(extract_dir)
44
+ Common::extract_zip(archive_file, extract_dir)
45
+ FileUtils::rm(archive_file) if Config[:delete_after_extraction]
46
+ rescue
47
+ Application.log(self, "extraction of '#{archive_file}' to '#{extract_dir}' failed:", $!)
48
+ end
49
+ end
50
+ end
51
+
52
+ dl.events.on(:failed) do |reason|
53
+ Application.log(self, dl.file, dl.url, reason)
54
+ FileUtils::rm(dl.file) rescue nil
55
+ end
56
+
57
+ @download << dl.start!
58
+ end
59
+
60
+ def get_track_file(url, reload: false)
61
+ begin
62
+ track_info = get_track_infos(url)
63
+ album_files = Dir.glob(File.join(track_info['album_path'], '*.mp3'))
64
+ track_file = album_files.sort[track_info['number']]
65
+ return track_file if track_file
66
+ rescue
67
+ Application.log(self, 'could not load track from archive_dir:', $!)
68
+ end
69
+
70
+ url_obj = URI.parse(url)
71
+ basename = File.basename(url_obj.path)
72
+ cache_file = File.join(Config[:cache_dir], basename)
73
+ temp_file = File.join(Config[:temp_dir], basename)
74
+
75
+ (File.delete(cache_file) rescue nil) if reload
76
+ (File.delete(temp_file) rescue nil) if reload
77
+
78
+ return cache_file if File.file?(cache_file)
79
+ return temp_file if File.file?(temp_file)
80
+
81
+ dl = DownloadThread.new(url, temp_file)
82
+
83
+ if Config[:use_cache]
84
+ dl.events.on(:completed) do
85
+ begin FileUtils::mv(temp_file, cache_file)
86
+ rescue
87
+ Application.log(self, 'mv failed', temp_file, cache_file, $!)
88
+ end
89
+ end
90
+ end
91
+
92
+ dl.events.on(:failed) do |reason|
93
+ Application.log(self, dl.file, dl.url, reason)
94
+ FileUtils::rm(dl.file) rescue nil
95
+ end
96
+
97
+ @downloads << dl.start!
98
+
99
+ return temp_file
100
+ end
101
+ end
102
+
103
+ class DownloadThread
104
+ attr_reader :events, :url, :progress, :total, :file, :filename, :error
105
+
106
+ def initialize(url, filename)
107
+ @events = Events.new(:completed, :failed, :progress)
108
+ @url = URI.parse(url)
109
+ @filename = filename
110
+ @file = File.open(filename, ?w)
111
+ @progress = 0
112
+ @error = nil
113
+ end
114
+
115
+ def start!
116
+ Thread.new do
117
+ begin
118
+ http = Net::HTTP.new(@url.host, @url.port)
119
+
120
+ http.request(Net::HTTP::Get.new(@url.request_uri)) do |res|
121
+ fail res.body unless res.code == '200'
122
+
123
+ @total = res.header['Content-Length'].to_i
124
+
125
+ res.read_body do |chunk|
126
+ @progress += chunk.size
127
+ @events.trigger(:progress, @progress)
128
+ @file << chunk
129
+ end
130
+ end
131
+
132
+ fail 'filesize mismatch' if @progress != @total
133
+ @events.trigger(:completed)
134
+ rescue
135
+ @events.trigger(:failed, (@error = $!))
136
+ ensure
137
+ @file.close
138
+ end
139
+ end
140
+
141
+ sleep 0.1 while @total.nil?
142
+ sleep 0.1
143
+ self
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,404 @@
1
+ require 'curses'
2
+ require 'readline'
3
+ require 'io/console'
4
+
5
+ require_relative 'ui/colors'
6
+ require_relative 'events'
7
+
8
+ module UI
9
+ class WidgetSizeError < Exception; end
10
+
11
+ class Canvas
12
+ extend Curses
13
+
14
+ def self.size
15
+ UI::Size.new(height: Curses.lines, width: Curses.cols)
16
+ end
17
+
18
+ def self.cursor
19
+ UI::Point.new(y: Curses.cury, x: Curses.curx)
20
+ end
21
+
22
+ def self.pos
23
+ UI::Point.new
24
+ end
25
+
26
+ def self.start
27
+ @@widget = nil
28
+
29
+ %w(init_screen crmode noecho start_color use_default_colors).
30
+ each {|_|Curses.send(_)}
31
+ Curses.mousemask(Curses::ALL_MOUSE_EVENTS)
32
+ Curses.stdscr.keypad(true)
33
+ [UI::Colors, UI::Input].each(&:start)
34
+
35
+ self.enable_resize_detection
36
+ end
37
+
38
+ def self.enable_resize_detection
39
+ @@winch_mutex ||= Mutex.new
40
+ @@winch_cond ||= ConditionVariable.new
41
+
42
+ Signal.trap('WINCH') { @@winch_cond.signal }
43
+
44
+ @@winch_thread ||= Thread.new do
45
+ loop do
46
+ @@winch_mutex.synchronize do
47
+ @@winch_cond.wait(@@winch_mutex)
48
+ self.on_winch
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ def self.widget; @@widget end
55
+ def self.widget=(w) @@widget = w end
56
+ def self.stop; Curses.close_screen end
57
+ def self.visible?; true end
58
+ def self.inivsibile?; false end
59
+
60
+ def self.on_winch
61
+ h, w = IO.console.winsize()
62
+ Curses.resizeterm(h, w)
63
+ Curses.refresh
64
+ @@widget.size=(Size.new(height: h, width: w)) if @@widget
65
+ rescue UI::WidgetSizeError
66
+ Curses.clear
67
+ Curses.addstr('terminal too small!')
68
+ Curses.refresh
69
+ end
70
+
71
+ def self.sub(cls, **opts)
72
+ @@widget ||= (widget = cls.new(parent: self, **opts))
73
+ widget
74
+ end
75
+
76
+ def self.getch(timeout=-1)
77
+ Curses.stdscr.timeout=(timeout)
78
+ UI::Input::KEYMAP_WORKAROUND[Curses.stdscr.getch]
79
+ end
80
+
81
+ def self.update_screen(force_redraw=false)
82
+ @@updating ||= Mutex.new
83
+
84
+ if @@updating.try_lock
85
+ if force_redraw
86
+ Curses.clear
87
+ Curses.refresh
88
+ end
89
+
90
+ begin
91
+ @@widget.display(true, force_redraw) if @@widget and (@@mode ||= :curses) == :curses
92
+ rescue UI::WidgetSizeError
93
+ Curses.clear
94
+ Curses.addstr('terminal too small!')
95
+ end
96
+
97
+ Curses.doupdate
98
+ @@updating.unlock
99
+ end
100
+ end
101
+
102
+ def self.run
103
+ self.start
104
+ return yield
105
+ ensure
106
+ self.stop
107
+ end
108
+ end
109
+
110
+ class Input
111
+ KEYMAP_WORKAROUND = {
112
+ 13 => Curses::KEY_ENTER,
113
+ 127 => Curses::KEY_BACKSPACE
114
+ }
115
+ KEYMAP_WORKAROUND.default_proc = proc { |h,k| k }
116
+ KEYMAP_WORKAROUND.freeze
117
+
118
+ def self.start
119
+ @@mode = :curses
120
+ Readline.input, @@readline_in_write = IO.pipe
121
+ Readline.output = File.open(File::NULL, ?w)
122
+ end
123
+
124
+ #def self.getch(timeout=-1)
125
+ # KEYMAP_WORKAROUND[@@widget.getch(timeout)]
126
+ #end
127
+
128
+ def self.start_loop
129
+ @@readline_mutex ||= Mutex.new
130
+ @@readline_cond ||= ConditionVariable.new
131
+
132
+ loop do
133
+ if @@mode == :curses
134
+ Curses.curs_set(0)
135
+ Curses.nonl
136
+
137
+ while @@mode == :curses
138
+ UI::Canvas.widget.win.keypad=(true)
139
+ c = KEYMAP_WORKAROUND[UI::Canvas.widget.win.getch1]
140
+
141
+ if c == Curses::KEY_MOUSE
142
+ if c = Curses.getmouse
143
+ UI::Canvas.widget.mouse_click(c)
144
+ end
145
+ elsif c # (not nil)
146
+ UI::Canvas.widget.key_press(c.is_a?(Integer) ? c : c.to_sym)
147
+ end
148
+ end
149
+ else
150
+ Curses.curs_set(1)
151
+ Curses.nl
152
+
153
+ while @@mode == :readline
154
+ win = UI::Canvas.widget.win
155
+ win.keypad=(false)
156
+ c = win.getch1
157
+
158
+ if c == 10 or c == 4
159
+ @@readline_thread.kill rescue nil
160
+ @@mode = :curses
161
+ else
162
+ @@readline_in_write.write(c.chr)
163
+
164
+ if c == 27 # pass 3-character escape sequence
165
+ if c = win.getch1(1)
166
+ @@readline_in_write.write(c.chr)
167
+ if c = win.getch1(1)
168
+ @@readline_in_write.write(c.chr)
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ @@readline_cond.signal
175
+ end
176
+ end
177
+ end
178
+ end
179
+
180
+ def self.readline(pos, size, prompt: '', add_hist: false)
181
+ @@mode = :readline
182
+
183
+ Readline.set_screen_size(size.height, size.width)
184
+ @@readline_thread ||= Thread.new do
185
+ begin
186
+ window = Curses::Window.new(size.height, size.width, pos.y, pos.x)
187
+ rl_thread = Thread.new { Readline.delete_text; Readline.readline }
188
+
189
+ while rl_thread.alive?
190
+ buffer = "#{prompt}#{Readline.line_buffer}"
191
+ window.erase
192
+ window << buffer[(buffer.size - size.width).clamp(0, buffer.size)..-1]
193
+ window.cursor=(Point.new(x: Readline.point + prompt.size, y: 0))
194
+ window.refresh
195
+ @@readline_mutex.synchronize { @@readline_cond.wait(@@readline_mutex, 0.3) }
196
+ end
197
+ ensure
198
+ rl_thread.kill
199
+ window.clear
200
+ yield Readline.line_buffer
201
+ @@mode = :curses
202
+ @@readline_thread = nil
203
+ Canvas.update_screen(true)
204
+ end
205
+ end
206
+ end
207
+ end
208
+
209
+ class Output
210
+ def self.error
211
+ fail NotImplementedError
212
+ end
213
+ end
214
+
215
+ class Point
216
+ attr_accessor :x, :y
217
+
218
+ def initialize(x: 0, y: 0)
219
+ @x, @y = x, y
220
+ end
221
+
222
+ def update(x: nil, y: nil)
223
+ Point.new(x: (x or @x), y: (y or @y))
224
+ end
225
+
226
+ def calc(x: 0, y: 0)
227
+ Point.new(x: @x + x, y: @y + y)
228
+ end
229
+
230
+ def >=(p) @x >= p.x and @y >= p.y end
231
+ def <=(p) @x <= p.x and @y <= p.y end
232
+ def ==(p) @x == p.x and @y == p.y end
233
+ def to_s; "[(Point) x=#{x}, y=#{y}]" end
234
+ end
235
+
236
+ class Size
237
+ attr_accessor :width, :height
238
+
239
+ def initialize(width: 0, height: 0)
240
+ @width, @height = width, height
241
+ end
242
+
243
+ def update(width: nil, height: nil)
244
+ Size.new(width: (width or @width), height: (height or @height))
245
+ end
246
+
247
+ def calc(height: 0, width: 0)
248
+ Size.new(height: @height + height, width: @width + width)
249
+ end
250
+
251
+ def ==(s) s.height == @height and s.width == @width end
252
+ def to_s; "[(Size) height=#{height}, width=#{width}]" end
253
+ end
254
+
255
+ # We want to change the mouse coordinates as we pass the mouse event
256
+ # through the widgets. The attributes of Curses::MouseEvent are
257
+ # readonly, therefore we need to carry out our own MouseEvent class.
258
+ class FakeMouseEvent
259
+ attr_accessor :x, :y, :z, :bstate
260
+
261
+ def initialize(mouse_event=nil)
262
+ if mouse_event
263
+ from_mouse_event!(mouse_event)
264
+ else
265
+ @x, @y, @z, @bstate = 0, 0, 0, Curses::BUTTON1_CLICKED
266
+ end
267
+ end
268
+
269
+ def from_mouse_event!(m)
270
+ @x, @y, @z, @bstate = m.x, m.y, m.z, m.bstate
271
+ end
272
+
273
+ def update!(x: nil, y: nil, z: nil, bstate: nil)
274
+ @x, @y, @z = (x or @x), (y or @y), (z or @z)
275
+ @bstate = (bstate or @bstate)
276
+ end
277
+
278
+ def pos
279
+ Point.new(x: @x, y: @y)
280
+ end
281
+
282
+ def to_fake
283
+ FakeMouseEvent.new(self)
284
+ end
285
+
286
+ def to_s
287
+ name = Curses.constants.
288
+ select { |c| c =~ /^BUTTON_/ }.
289
+ select { |c| Curses.const_get(c) & @bstate > 0 }[0]
290
+ name ||= @button
291
+ "[(FakeMouseEvent) button=#{name}, x=#{x}, y=#{y}, z=#{z}]"
292
+ end
293
+ end
294
+
295
+ class MouseEvents < Events
296
+ def on(mouse_event, &block)
297
+ return on_all(&block) if mouse_event == Curses::ALL_MOUSE_EVENTS
298
+ super(mouse_event, &block)
299
+ end
300
+
301
+ def trigger(mouse_event)
302
+ super(mouse_event.bstate, mouse_event)
303
+ end
304
+ end
305
+
306
+ class MouseSectionEvents
307
+ def initialize
308
+ @events = []
309
+ end
310
+
311
+ def clear
312
+ @events.clear
313
+ end
314
+
315
+ def add(mouse_section_event)
316
+ @events << mouse_section_event
317
+ end
318
+
319
+ def trigger(mevent)
320
+ @events.each { |e| e.trigger(mevent) }
321
+ end
322
+ end
323
+
324
+ class MouseSectionEvent
325
+ def initialize(start=nil, stop=nil)
326
+ @start_pos, @stop_pos, @events = start, stop, MouseEvents.new
327
+ end
328
+
329
+ def on(button, &block); @events.on(button, &block) end
330
+
331
+ def trigger(mevent)
332
+ return unless mevent.pos >= @start_pos and mevent.pos <= @stop_pos
333
+ @events.trigger(mevent)
334
+ end
335
+ end
336
+ end
337
+
338
+ module Curses
339
+ class Window
340
+ alias :height :maxy
341
+ alias :width :maxx
342
+ alias :clear_line :clrtoeol
343
+
344
+ def cursor; UI::Point.new(y: cury, x: curx) end
345
+ def pos; UI::Point.new(y: begy, x: begx) end
346
+ def size; UI::Size.new(height: maxy, width: maxx) end
347
+
348
+ def cursor=(new)
349
+ setpos(new.y, new.x) # or fail "Could not set cursor: #{new} #{size}"
350
+ end
351
+
352
+ def pos=(new)
353
+ move(new.y, new.x)
354
+ end
355
+
356
+ def size=(new)
357
+ resize(new.height, new.width) or fail "Could not resize: #{new}"
358
+ end
359
+
360
+ def with_attr(attr)
361
+ attron(attr); yield; attroff(attr)
362
+ end
363
+
364
+ def getch1(timeout=-1)
365
+ self.timeout=(timeout)
366
+ getch
367
+ end
368
+
369
+ def on_line(n) setpos(n, curx) ;self;end
370
+ def on_column(n) setpos(cury, n) ;self;end
371
+ def next_line; setpos(cury + 1, 0) ;self;end
372
+ def mv_left(n) setpos(cury, curx - 1) ;self;end
373
+ def line_start(l=0) setpos(l, 0) ;self;end
374
+ def from_left(size) setpos(cury, size) ;self;end
375
+ def from_right(size) setpos(cury, (maxx - size)) ;self;end
376
+ def center(size) setpos(cury, (maxx / 2) - (size / 2)) ;self;end
377
+
378
+ def center_string(string)
379
+ center(string.size)
380
+ addstr(string)
381
+ self end
382
+
383
+ def insert_top
384
+ setpos(0, 0)
385
+ insertln
386
+ self end
387
+
388
+ def append_bottom
389
+ setpos(0, 0)
390
+ deleteln
391
+ setpos(maxy - 1, 0)
392
+ self end
393
+ end
394
+
395
+ class MouseEvent
396
+ def pos
397
+ UI::Point.new(x: x, y: y)
398
+ end
399
+
400
+ def to_fake
401
+ UI::FakeMouseEvent.new(self)
402
+ end
403
+ end
404
+ end