ektoplayer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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