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.
- checksums.yaml +7 -0
- data/README.md +49 -0
- data/bin/ektoplayer +7 -0
- data/lib/ektoplayer.rb +10 -0
- data/lib/ektoplayer/application.rb +148 -0
- data/lib/ektoplayer/bindings.rb +230 -0
- data/lib/ektoplayer/browsepage.rb +138 -0
- data/lib/ektoplayer/client.rb +18 -0
- data/lib/ektoplayer/common.rb +91 -0
- data/lib/ektoplayer/config.rb +247 -0
- data/lib/ektoplayer/controllers/browser.rb +47 -0
- data/lib/ektoplayer/controllers/controller.rb +9 -0
- data/lib/ektoplayer/controllers/help.rb +21 -0
- data/lib/ektoplayer/controllers/info.rb +22 -0
- data/lib/ektoplayer/controllers/mainwindow.rb +40 -0
- data/lib/ektoplayer/controllers/playlist.rb +60 -0
- data/lib/ektoplayer/database.rb +199 -0
- data/lib/ektoplayer/events.rb +56 -0
- data/lib/ektoplayer/models/browser.rb +127 -0
- data/lib/ektoplayer/models/database.rb +49 -0
- data/lib/ektoplayer/models/model.rb +15 -0
- data/lib/ektoplayer/models/player.rb +28 -0
- data/lib/ektoplayer/models/playlist.rb +72 -0
- data/lib/ektoplayer/models/search.rb +42 -0
- data/lib/ektoplayer/models/trackloader.rb +17 -0
- data/lib/ektoplayer/mp3player.rb +151 -0
- data/lib/ektoplayer/operations/browser.rb +19 -0
- data/lib/ektoplayer/operations/operations.rb +26 -0
- data/lib/ektoplayer/operations/player.rb +11 -0
- data/lib/ektoplayer/operations/playlist.rb +67 -0
- data/lib/ektoplayer/theme.rb +102 -0
- data/lib/ektoplayer/trackloader.rb +146 -0
- data/lib/ektoplayer/ui.rb +404 -0
- data/lib/ektoplayer/ui/colors.rb +105 -0
- data/lib/ektoplayer/ui/widgets.rb +195 -0
- data/lib/ektoplayer/ui/widgets/container.rb +125 -0
- data/lib/ektoplayer/ui/widgets/labelwidget.rb +43 -0
- data/lib/ektoplayer/ui/widgets/listwidget.rb +332 -0
- data/lib/ektoplayer/ui/widgets/tabbedcontainer.rb +110 -0
- data/lib/ektoplayer/updater.rb +77 -0
- data/lib/ektoplayer/views/browser.rb +25 -0
- data/lib/ektoplayer/views/help.rb +46 -0
- data/lib/ektoplayer/views/info.rb +208 -0
- data/lib/ektoplayer/views/mainwindow.rb +64 -0
- data/lib/ektoplayer/views/playinginfo.rb +135 -0
- data/lib/ektoplayer/views/playlist.rb +39 -0
- data/lib/ektoplayer/views/progressbar.rb +51 -0
- data/lib/ektoplayer/views/splash.rb +99 -0
- data/lib/ektoplayer/views/trackrenderer.rb +137 -0
- data/lib/ektoplayer/views/volumemeter.rb +74 -0
- 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
|