优化线程通讯, mpd改为基于Gtk的信号槽机制通讯
parent
1b4834d022
commit
271a8838fb
24
main.py
24
main.py
|
@ -19,6 +19,7 @@ app_id = 'fun.wkit.sonist'
|
||||||
home_dir = os.getenv('HOME')
|
home_dir = os.getenv('HOME')
|
||||||
|
|
||||||
|
|
||||||
|
# 定义一个异步修饰器, 用于在子线程中运行一些会阻塞主线程的任务
|
||||||
def run_async(func):
|
def run_async(func):
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
|
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
|
||||||
|
@ -27,7 +28,7 @@ def run_async(func):
|
||||||
return thread
|
return thread
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
# 类型js的settimeout的修饰器
|
||||||
def set_timeout(timeout = 0.5):
|
def set_timeout(timeout = 0.5):
|
||||||
def decorator(callback):
|
def decorator(callback):
|
||||||
def wrapper(*args):
|
def wrapper(*args):
|
||||||
|
@ -39,6 +40,7 @@ def set_timeout(timeout = 0.5):
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_music_dir():
|
def get_music_dir():
|
||||||
with open(f'{home_dir}/.mpd/mpd.conf', 'r') as f:
|
with open(f'{home_dir}/.mpd/mpd.conf', 'r') as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
|
@ -52,38 +54,38 @@ def get_music_dir():
|
||||||
|
|
||||||
class Application(Gtk.Application):
|
class Application(Gtk.Application):
|
||||||
|
|
||||||
__gsignals__ = {
|
|
||||||
'playing': (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
|
||||||
'song_changed': (GObject.SignalFlags.RUN_FIRST, None, (bool,)),
|
|
||||||
'state_changed': (GObject.SignalFlags.RUN_FIRST, None, (str,))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Gtk.Application.__init__(self, application_id = app_id)
|
Gtk.Application.__init__(self, application_id = app_id)
|
||||||
|
|
||||||
self.timer = None
|
self.timer = None
|
||||||
|
|
||||||
self.mpd = MPDClient()
|
|
||||||
|
|
||||||
self.music_dir = get_music_dir()
|
self.music_dir = get_music_dir()
|
||||||
|
self.mpd = MPDClient()
|
||||||
|
|
||||||
self.connect('window-removed', self.on_window_removed)
|
self.connect('window-removed', self.on_window_removed)
|
||||||
|
|
||||||
self.mpd.connect()
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@run_async
|
||||||
|
def connect_mpd(self):
|
||||||
|
|
||||||
|
self.mpd.start()
|
||||||
|
|
||||||
def do_activate(self):
|
def do_activate(self):
|
||||||
print('hello mpc')
|
print('hello mpc')
|
||||||
self.set_app_menu(None)
|
self.set_app_menu(None)
|
||||||
self.set_menubar(None)
|
self.set_menubar(None)
|
||||||
|
|
||||||
|
|
||||||
self.window = SonistWindow(self)
|
self.window = SonistWindow(self)
|
||||||
self.about = AboutWindow()
|
self.about = AboutWindow()
|
||||||
self.add_window(self.window)
|
self.add_window(self.window)
|
||||||
self.window.show_all()
|
self.window.show_all()
|
||||||
# self.about.show_all()
|
# self.about.show_all()
|
||||||
|
|
||||||
|
self.connect_mpd()
|
||||||
|
|
||||||
|
|
||||||
def on_window_removed(self, app, win):
|
def on_window_removed(self, app, win):
|
||||||
if len(self.get_windows()) == 0:
|
if len(self.get_windows()) == 0:
|
||||||
|
|
144
mpd.py
144
mpd.py
|
@ -17,10 +17,12 @@
|
||||||
# You should have received a copy of the GNU Lesser General Public License
|
# You should have received a copy of the GNU Lesser General Public License
|
||||||
# along with python-mpd2. If not, see <http://www.gnu.org/licenses/>.
|
# along with python-mpd2. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import re, socket, sys, warnings, threading, time
|
import gi
|
||||||
|
import re, socket, sys, warnings, time
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
gi.require_version('Gtk', '3.0')
|
||||||
|
from gi.repository import GObject
|
||||||
|
|
||||||
VERSION = (3, 1, 0)
|
VERSION = (3, 1, 0)
|
||||||
HELLO_PREFIX = "OK MPD "
|
HELLO_PREFIX = "OK MPD "
|
||||||
|
@ -30,24 +32,6 @@ SUCCESS = "OK"
|
||||||
NEXT = "list_OK"
|
NEXT = "list_OK"
|
||||||
|
|
||||||
|
|
||||||
def run_async(func):
|
|
||||||
def wrapper(*args, **kwargs):
|
|
||||||
thread = threading.Thread(target=func, args=args, kwargs=kwargs)
|
|
||||||
thread.daemon = True
|
|
||||||
thread.start()
|
|
||||||
return thread
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
def set_timeout(timeout = 0.5):
|
|
||||||
def decorator(callback):
|
|
||||||
def wrapper(*args):
|
|
||||||
t = threading.Timer(timeout, callback, args=args)
|
|
||||||
t.start()
|
|
||||||
return t
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
|
|
||||||
def escape(text):
|
def escape(text):
|
||||||
return text.replace("\\", "\\\\").replace('"', '\\"')
|
return text.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
@ -72,19 +56,11 @@ class FailureResponseCode(Enum):
|
||||||
EXIST = 56
|
EXIST = 56
|
||||||
|
|
||||||
|
|
||||||
class MPDError(Exception):
|
class ProtocolError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConnectionError(MPDError):
|
class CommandError(Exception):
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ProtocolError(MPDError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class CommandError(MPDError):
|
|
||||||
def __init__(self, error):
|
def __init__(self, error):
|
||||||
self.errno = None
|
self.errno = None
|
||||||
self.offset = None
|
self.offset = None
|
||||||
|
@ -99,15 +75,11 @@ class CommandError(MPDError):
|
||||||
self.msg = match.group("msg")
|
self.msg = match.group("msg")
|
||||||
|
|
||||||
|
|
||||||
class CommandListError(MPDError):
|
class CommandListError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PendingCommandError(MPDError):
|
class IteratingError(Exception):
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class IteratingError(MPDError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@ -171,7 +143,7 @@ class Noop(object):
|
||||||
mpd_commands = None
|
mpd_commands = None
|
||||||
|
|
||||||
|
|
||||||
class MPDClientBase(object):
|
class MPDClientBase():
|
||||||
"""Abstract MPD client.
|
"""Abstract MPD client.
|
||||||
|
|
||||||
This class defines a general client contract, provides MPD protocol parsers
|
This class defines a general client contract, provides MPD protocol parsers
|
||||||
|
@ -467,18 +439,19 @@ def _create_command(wrapper, name, return_value, wrap_result):
|
||||||
return mpd_command
|
return mpd_command
|
||||||
|
|
||||||
|
|
||||||
class _NotConnected(object):
|
|
||||||
def __getattr__(self, attr):
|
|
||||||
return self._dummy
|
|
||||||
|
|
||||||
def _dummy(*args):
|
|
||||||
raise ConnectionError("Not connected")
|
|
||||||
|
|
||||||
|
|
||||||
@mpd_command_provider
|
@mpd_command_provider
|
||||||
class MPDClient(MPDClientBase):
|
class MPDClient(MPDClientBase, GObject.Object):
|
||||||
|
|
||||||
__events__ = {}
|
__gsignals__ = {
|
||||||
|
'online': (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
|
'offline': (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||||
|
'playing': (GObject.SignalFlags.RUN_FIRST, None, (object, object)),
|
||||||
|
'song_changed': (GObject.SignalFlags.RUN_FIRST, None, (object, object)),
|
||||||
|
'state_changed': (GObject.SignalFlags.RUN_FIRST, None, (object,))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# __events__ = {}
|
||||||
|
|
||||||
connected = False
|
connected = False
|
||||||
|
|
||||||
|
@ -508,6 +481,7 @@ class MPDClient(MPDClientBase):
|
||||||
|
|
||||||
def __init__(self, host = '127.0.0.1', port = 6600):
|
def __init__(self, host = '127.0.0.1', port = 6600):
|
||||||
super(MPDClient, self).__init__()
|
super(MPDClient, self).__init__()
|
||||||
|
super(GObject.Object, self).__init__()
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
|
|
||||||
|
@ -519,16 +493,6 @@ class MPDClient(MPDClientBase):
|
||||||
self._rbfile = None
|
self._rbfile = None
|
||||||
self._wfile = None
|
self._wfile = None
|
||||||
|
|
||||||
def bind(self, event, callback):
|
|
||||||
self.__events__[event] = callback
|
|
||||||
|
|
||||||
@run_async
|
|
||||||
def emit(self, event, *args):
|
|
||||||
# print('emit: ', event, args)
|
|
||||||
callback = self.__events__.get(event)
|
|
||||||
if callback is not None:
|
|
||||||
callback(*args)
|
|
||||||
|
|
||||||
def _execute(self, command, args, retval):
|
def _execute(self, command, args, retval):
|
||||||
if self._iterating:
|
if self._iterating:
|
||||||
raise IteratingError("Cannot execute '{}' while iterating".format(command))
|
raise IteratingError("Cannot execute '{}' while iterating".format(command))
|
||||||
|
@ -579,7 +543,7 @@ class MPDClient(MPDClientBase):
|
||||||
def _read_line(self):
|
def _read_line(self):
|
||||||
line = self._rbfile.readline().decode("utf-8")
|
line = self._rbfile.readline().decode("utf-8")
|
||||||
if not line.endswith("\n"):
|
if not line.endswith("\n"):
|
||||||
self.disconnect()
|
self.end()
|
||||||
raise ConnectionError("Connection lost while reading line")
|
raise ConnectionError("Connection lost while reading line")
|
||||||
line = line.rstrip("\n")
|
line = line.rstrip("\n")
|
||||||
if line.startswith(ERROR_PREFIX):
|
if line.startswith(ERROR_PREFIX):
|
||||||
|
@ -633,7 +597,7 @@ class MPDClient(MPDClientBase):
|
||||||
value = self._read_chunk(chunk_size)
|
value = self._read_chunk(chunk_size)
|
||||||
|
|
||||||
if len(value) != chunk_size:
|
if len(value) != chunk_size:
|
||||||
self.disconnect()
|
self.end()
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
"Connection lost while reading binary data: "
|
"Connection lost while reading binary data: "
|
||||||
"expected %d bytes, got %d" % (chunk_size, len(value))
|
"expected %d bytes, got %d" % (chunk_size, len(value))
|
||||||
|
@ -641,7 +605,7 @@ class MPDClient(MPDClientBase):
|
||||||
|
|
||||||
if self._rbfile.read(1) != b"\n":
|
if self._rbfile.read(1) != b"\n":
|
||||||
# newline after binary content
|
# newline after binary content
|
||||||
self.disconnect()
|
self.end()
|
||||||
raise ConnectionError("Connection lost while reading line")
|
raise ConnectionError("Connection lost while reading line")
|
||||||
|
|
||||||
obj[key] = value
|
obj[key] = value
|
||||||
|
@ -721,7 +685,7 @@ class MPDClient(MPDClientBase):
|
||||||
|
|
||||||
def _check_is_mpd_server(self, line):
|
def _check_is_mpd_server(self, line):
|
||||||
if not line.endswith("\n"):
|
if not line.endswith("\n"):
|
||||||
self.disconnect()
|
self.end()
|
||||||
return
|
return
|
||||||
|
|
||||||
line = line.rstrip("\n")
|
line = line.rstrip("\n")
|
||||||
|
@ -761,7 +725,7 @@ class MPDClient(MPDClientBase):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
||||||
if e.strerror == 'Connection refused':
|
if e.strerror == 'Connection refused':
|
||||||
self.emit('offline')
|
self.emit('offline', True)
|
||||||
else:
|
else:
|
||||||
self.emit('error', e)
|
self.emit('error', e)
|
||||||
|
|
||||||
|
@ -786,11 +750,12 @@ class MPDClient(MPDClientBase):
|
||||||
if self._sock is not None:
|
if self._sock is not None:
|
||||||
self._sock.settimeout(timeout)
|
self._sock.settimeout(timeout)
|
||||||
|
|
||||||
@set_timeout(2)
|
|
||||||
def _try_connect(self):
|
def _try_connect(self):
|
||||||
|
|
||||||
|
while True:
|
||||||
if self._sock is not None:
|
if self._sock is not None:
|
||||||
raise ConnectionError("Already connected")
|
break
|
||||||
|
|
||||||
if self.host.startswith(("/", "\0")):
|
if self.host.startswith(("/", "\0")):
|
||||||
self._sock = self._connect_unix()
|
self._sock = self._connect_unix()
|
||||||
|
@ -798,16 +763,7 @@ class MPDClient(MPDClientBase):
|
||||||
self._sock = self._connect_tcp()
|
self._sock = self._connect_tcp()
|
||||||
|
|
||||||
|
|
||||||
# - Force UTF-8 encoding, since this is dependant from the LC_CTYPE
|
if self._sock is not None:
|
||||||
# locale.
|
|
||||||
# - by setting newline explicit, we force to send '\n' also on
|
|
||||||
# windows
|
|
||||||
if self._sock is None:
|
|
||||||
self.connected = False
|
|
||||||
self.connect()
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
self._rbfile = self._sock.makefile("rb", newline="\n")
|
self._rbfile = self._sock.makefile("rb", newline="\n")
|
||||||
self._wfile = self._sock.makefile("w", encoding="utf-8", newline="\n")
|
self._wfile = self._sock.makefile("w", encoding="utf-8", newline="\n")
|
||||||
|
|
||||||
|
@ -815,23 +771,16 @@ class MPDClient(MPDClientBase):
|
||||||
helloline = self._rbfile.readline().decode("utf-8")
|
helloline = self._rbfile.readline().decode("utf-8")
|
||||||
self._check_is_mpd_server(helloline)
|
self._check_is_mpd_server(helloline)
|
||||||
if self.connected:
|
if self.connected:
|
||||||
self.emit('online')
|
break
|
||||||
self.heart_beat_timer = self.heart_beat()
|
|
||||||
else:
|
|
||||||
self.emit('error', ProtocolError('Connected server is not mpd server.'))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.connected = False
|
self.connected = False
|
||||||
self.emit('error', e)
|
self.end()
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
@set_timeout(0.2)
|
|
||||||
def heart_beat(self):
|
def heart_beat(self):
|
||||||
|
|
||||||
if self.heart_beat_timer is not None:
|
while True:
|
||||||
self.heart_beat_timer.cancel()
|
|
||||||
|
|
||||||
if self.connected:
|
if self.connected:
|
||||||
try:
|
try:
|
||||||
status = self.status()
|
status = self.status()
|
||||||
|
@ -852,26 +801,29 @@ class MPDClient(MPDClientBase):
|
||||||
if state == 'play':
|
if state == 'play':
|
||||||
self.emit('playing', status, song)
|
self.emit('playing', status, song)
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
self.heart_beat_timer = self.heart_beat()
|
|
||||||
|
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
print(err)
|
print(err)
|
||||||
self.disconnect()
|
|
||||||
self.connect()
|
|
||||||
|
|
||||||
def connect(self):
|
else:
|
||||||
self.try_conn_timer = self._try_connect()
|
break
|
||||||
|
|
||||||
|
self.end()
|
||||||
|
self.start()
|
||||||
|
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self._try_connect()
|
||||||
|
if self.connected:
|
||||||
|
self.emit('online')
|
||||||
|
self.heart_beat()
|
||||||
|
|
||||||
def destroy(self):
|
def destroy(self):
|
||||||
if self.try_conn_timer is not None:
|
self.end()
|
||||||
self.try_conn_timer.cancel()
|
|
||||||
if self.heart_beat_timer is not None:
|
|
||||||
self.heart_beat_timer.cancel()
|
|
||||||
|
|
||||||
self._reset()
|
|
||||||
|
|
||||||
def disconnect(self):
|
def end(self):
|
||||||
if self._rbfile is not None:
|
if self._rbfile is not None:
|
||||||
self._rbfile.close()
|
self._rbfile.close()
|
||||||
if self._wfile is not None:
|
if self._wfile is not None:
|
||||||
|
|
34
window.py
34
window.py
|
@ -1,11 +1,11 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import gi, sys, os, mutagen
|
import gi, sys, os, mutagen, threading
|
||||||
# from pprint import pprint as print
|
# from pprint import pprint as print
|
||||||
|
|
||||||
gi.require_version('Gtk', '3.0')
|
gi.require_version('Gtk', '3.0')
|
||||||
|
|
||||||
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf
|
from gi.repository import Gtk, Gdk, GLib, GdkPixbuf, GObject
|
||||||
|
|
||||||
from utils import blur_image, pic_to_pixbuf, base64_to_pixbuf
|
from utils import blur_image, pic_to_pixbuf, base64_to_pixbuf
|
||||||
|
|
||||||
|
@ -18,7 +18,11 @@ from ui.timebar import Timebar
|
||||||
from ui.option_menu import OptionMenu
|
from ui.option_menu import OptionMenu
|
||||||
|
|
||||||
|
|
||||||
|
# 定义一个修饰器, 用于将当前方法转到主线程中运行 (子线程中调用方法时)
|
||||||
|
def idle(func):
|
||||||
|
def wrapper(*args):
|
||||||
|
GObject.idle_add(func, *args)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class SonistWindow(Gtk.Window):
|
class SonistWindow(Gtk.Window):
|
||||||
|
@ -34,12 +38,11 @@ class SonistWindow(Gtk.Window):
|
||||||
|
|
||||||
self.connect("destroy", self.quited)
|
self.connect("destroy", self.quited)
|
||||||
|
|
||||||
self.mpd.bind('offline', lambda : self.reset_player())
|
self.mpd.connect('offline', lambda o: self.reset_player())
|
||||||
self.mpd.bind('online', lambda : self.sync_state(None, None, True))
|
self.mpd.connect('online', lambda o: self.sync_state(None, None, True))
|
||||||
self.mpd.bind('state_changed', lambda stat: self.update_play_stat(stat == 'play'))
|
self.mpd.connect('state_changed', lambda o, stat: self.update_play_stat(stat == 'play'))
|
||||||
self.mpd.bind('song_changed', lambda stat, song: self.sync_state(stat, song, False))
|
self.mpd.connect('song_changed', lambda o, stat, song: self.sync_state(stat, song, False))
|
||||||
self.mpd.bind('playing', lambda stat, song: self.update_playtime(stat))
|
self.mpd.connect('playing', lambda o, stat, song: self.update_playtime(stat))
|
||||||
|
|
||||||
|
|
||||||
self.set_name('SonistWindow')
|
self.set_name('SonistWindow')
|
||||||
self.set_default_size(320, 384)
|
self.set_default_size(320, 384)
|
||||||
|
@ -109,6 +112,9 @@ class SonistWindow(Gtk.Window):
|
||||||
self.add(layout)
|
self.add(layout)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_mpd_stat(self):
|
def get_mpd_stat(self):
|
||||||
try:
|
try:
|
||||||
self.stat = self.mpd.status()
|
self.stat = self.mpd.status()
|
||||||
|
@ -201,7 +207,7 @@ class SonistWindow(Gtk.Window):
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@idle
|
||||||
def update_play_stat(self, played = True):
|
def update_play_stat(self, played = True):
|
||||||
if not self.mpd.connected:
|
if not self.mpd.connected:
|
||||||
return
|
return
|
||||||
|
@ -214,13 +220,16 @@ class SonistWindow(Gtk.Window):
|
||||||
# 切换播放按钮状态
|
# 切换播放按钮状态
|
||||||
self.ctrl_box.toggle_play_btn(played)
|
self.ctrl_box.toggle_play_btn(played)
|
||||||
|
|
||||||
|
@idle
|
||||||
def update_playtime(self, stat = {}):
|
def update_playtime(self, stat = {}):
|
||||||
times = stat['time'].split(':')
|
times = stat['time'].split(':')
|
||||||
self.timebar.update_time(int(times[0]), int(times[1]))
|
self.timebar.update_time(int(times[0]), int(times[1]))
|
||||||
|
|
||||||
|
@idle
|
||||||
def sync_state(self, stat = None, song = None, first = False):
|
def sync_state(self, stat = None, song = None, first = False):
|
||||||
self.ctrl_box.disabled = False
|
self.ctrl_box.disabled = False
|
||||||
|
print(threading.active_count())
|
||||||
|
print(threading.current_thread())
|
||||||
|
|
||||||
self.stat = stat or self.get_mpd_stat()
|
self.stat = stat or self.get_mpd_stat()
|
||||||
|
|
||||||
|
@ -262,16 +271,19 @@ class SonistWindow(Gtk.Window):
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@idle
|
||||||
def reset_player(self):
|
def reset_player(self):
|
||||||
self.ctrl_box.disabled = True
|
self.ctrl_box.disabled = True
|
||||||
self.title_box.set_text('mpd is offline...')
|
self.title_box.set_text('mpd is offline...')
|
||||||
self.timebar.update_time()
|
self.timebar.update_time()
|
||||||
self.update_album('./usr/share/sonist/avatar.jpg')
|
self.update_album('./usr/share/sonist/avatar.jpg')
|
||||||
|
|
||||||
|
@idle
|
||||||
def update_album(self, filepath):
|
def update_album(self, filepath):
|
||||||
self.set_background_image(filepath)
|
self.set_background_image(filepath)
|
||||||
self.album.reset(filepath).set_radius(64)
|
self.album.reset(filepath).set_radius(64)
|
||||||
|
|
||||||
|
|
||||||
def quited(self, win):
|
def quited(self, win):
|
||||||
self.app.remove_window(self)
|
self.app.remove_window(self)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue