1 | #!/usr/bin/env python |
||
2 | |||
3 | 1 | """Graphical interface for DropTheBeat.""" |
|
4 | |||
5 | 1 | import sys |
|
6 | 1 | from unittest.mock import Mock |
|
7 | 1 | try: |
|
8 | 1 | import tkinter as tk |
|
9 | 1 | from tkinter import ttk |
|
10 | 1 | from tkinter import messagebox, simpledialog, filedialog |
|
11 | except ImportError as err: |
||
12 | sys.stderr.write("WARNING: {}\n".format(err)) |
||
13 | tk = Mock() |
||
14 | ttk = Mock() |
||
15 | |||
16 | 1 | import os |
|
17 | 1 | import argparse |
|
18 | 1 | from itertools import chain |
|
19 | 1 | import logging |
|
20 | |||
21 | 1 | from dtb import GUI, __version__ |
|
22 | 1 | from dtb import share, user |
|
23 | 1 | from dtb.common import SHARED, WarningFormatter |
|
24 | 1 | from dtb import settings |
|
25 | |||
26 | 1 | _LAUNCH = True |
|
27 | |||
28 | |||
29 | 1 | class Application(ttk.Frame): # pylint: disable=too-many-instance-attributes |
|
30 | """Tkinter application for DropTheBeat.""" |
||
31 | |||
32 | 1 | def __init__(self, master=None, root=None, home=None, name=None): |
|
33 | ttk.Frame.__init__(self, master) |
||
34 | |||
35 | # Load the root sharing directory |
||
36 | self.root = root or share.find(home) |
||
37 | |||
38 | # Load the user |
||
39 | self.user = user.User(os.path.join(self.root, name)) if name else None |
||
40 | try: |
||
41 | self.user = self.user or user.get_current(self.root) |
||
42 | except EnvironmentError: |
||
43 | |||
44 | while True: |
||
45 | |||
46 | msg = "Enter your name in the form 'First Last':" |
||
47 | text = simpledialog.askstring("Create a User", msg) |
||
48 | logging.debug("text: {}".format(repr(text))) |
||
49 | name = text.strip(" '") if text else None |
||
50 | if not name: |
||
51 | raise KeyboardInterrupt("no user specified") |
||
52 | try: |
||
53 | self.user = user.User.new(self.root, name) |
||
54 | except EnvironmentError: |
||
55 | existing = user.User(os.path.join(self.root, name)) |
||
56 | msg = "Is this you:" |
||
57 | for info in existing.info: |
||
58 | msg += "\n\n'{}' on '{}'".format(info[1], info[0]) |
||
59 | if not existing.info or \ |
||
60 | messagebox.askyesno("Add to Existing User", msg): |
||
61 | self.user = user.User.add(self.root, name) |
||
62 | break |
||
63 | else: |
||
64 | break |
||
65 | |||
66 | # Create variables |
||
67 | self.path_root = tk.StringVar(value=self.root) |
||
68 | self.path_downloads = tk.StringVar(value=self.user.path_downloads) |
||
69 | self.outgoing = [] |
||
70 | self.incoming = [] |
||
71 | |||
72 | # Initialize the GUI |
||
73 | self.listbox_outgoing = None |
||
74 | self.listbox_incoming = None |
||
75 | frame = self.init(master) |
||
76 | frame.pack(fill=tk.BOTH, expand=1) |
||
77 | |||
78 | # Show the GUI |
||
79 | master.deiconify() |
||
80 | self.update() |
||
81 | |||
82 | 1 | def init(self, root): |
|
83 | """Initialize frames and widgets.""" |
||
84 | |||
85 | # pylint: disable=line-too-long |
||
86 | |||
87 | mac = sys.platform == 'darwin' |
||
88 | |||
89 | # Shared keyword arguments |
||
90 | kw_f = {'padding': 5} # constructor arguments for frames |
||
91 | kw_gp = {'padx': 5, 'pady': 5} # grid arguments for padded widgets |
||
92 | kw_gs = {'sticky': tk.NSEW} # grid arguments for sticky widgets |
||
93 | kw_gsp = dict(chain(kw_gs.items(), kw_gp.items())) # grid arguments for sticky padded widgets |
||
94 | |||
95 | # Configure grid |
||
96 | frame = ttk.Frame(root, **kw_f) |
||
97 | frame.rowconfigure(0, weight=0) |
||
98 | frame.rowconfigure(2, weight=1) |
||
99 | frame.rowconfigure(4, weight=1) |
||
100 | frame.columnconfigure(0, weight=1) |
||
101 | |||
102 | # Create widgets |
||
103 | def frame_settings(master): |
||
104 | """Frame for the settings.""" |
||
105 | frame = ttk.Frame(master, **kw_f) |
||
106 | |||
107 | # Configure grid |
||
108 | frame.rowconfigure(0, weight=1) |
||
109 | frame.rowconfigure(1, weight=1) |
||
110 | frame.columnconfigure(0, weight=0) |
||
111 | frame.columnconfigure(1, weight=1) |
||
112 | frame.columnconfigure(2, weight=0) |
||
113 | |||
114 | # Place widgets |
||
115 | ttk.Label(frame, text="Shared:").grid(row=0, column=0, sticky=tk.W, **kw_gp) |
||
116 | ttk.Entry(frame, state='readonly', textvariable=self.path_root).grid(row=0, column=1, columnspan=2, **kw_gsp) |
||
117 | ttk.Label(frame, text="Downloads:").grid(row=1, column=0, sticky=tk.W, **kw_gp) |
||
118 | ttk.Entry(frame, state='readonly', textvariable=self.path_downloads).grid(row=1, column=1, **kw_gsp) |
||
119 | ttk.Button(frame, text="...", width=0, command=self.browse_downloads).grid(row=1, column=2, ipadx=5, **kw_gp) |
||
120 | |||
121 | return frame |
||
122 | |||
123 | View Code Duplication | def frame_incoming(master): |
|
0 ignored issues
–
show
Duplication
introduced
by
![]() |
|||
124 | """Frame for incoming songs.""" |
||
125 | frame = ttk.Frame(master, **kw_f) |
||
126 | |||
127 | # Configure grid |
||
128 | frame.rowconfigure(0, weight=1) |
||
129 | frame.rowconfigure(1, weight=0) |
||
130 | frame.columnconfigure(0, weight=0) |
||
131 | frame.columnconfigure(1, weight=1) |
||
132 | frame.columnconfigure(2, weight=1) |
||
133 | |||
134 | # Place widgets |
||
135 | self.listbox_incoming = tk.Listbox(frame, selectmode=tk.EXTENDED if mac else tk.MULTIPLE) |
||
136 | self.listbox_incoming.grid(row=0, column=0, columnspan=3, **kw_gsp) |
||
137 | scroll_incoming = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.listbox_incoming.yview) |
||
138 | self.listbox_incoming.configure(yscrollcommand=scroll_incoming.set) |
||
139 | scroll_incoming.grid(row=0, column=2, sticky=(tk.N, tk.E, tk.S)) |
||
140 | ttk.Button(frame, text="\u21BB", width=0, command=self.update).grid(row=1, column=0, sticky=tk.SW, ipadx=5, **kw_gp) |
||
141 | ttk.Button(frame, text="Ignore Selected", command=self.do_ignore).grid(row=1, column=1, sticky=tk.SW, ipadx=5, **kw_gp) |
||
142 | ttk.Button(frame, text="Download Selected", command=self.do_download).grid(row=1, column=2, sticky=tk.SE, ipadx=5, **kw_gp) |
||
143 | return frame |
||
144 | |||
145 | View Code Duplication | def frame_outgoing(master): |
|
0 ignored issues
–
show
|
|||
146 | """Frame for outgoing songs.""" |
||
147 | frame = ttk.Frame(master, **kw_f) |
||
148 | |||
149 | # Configure grid |
||
150 | frame.rowconfigure(0, weight=1) |
||
151 | frame.rowconfigure(1, weight=0) |
||
152 | frame.columnconfigure(0, weight=0) |
||
153 | frame.columnconfigure(1, weight=1) |
||
154 | frame.columnconfigure(2, weight=1) |
||
155 | |||
156 | # Place widgets |
||
157 | self.listbox_outgoing = tk.Listbox(frame, selectmode=tk.EXTENDED if mac else tk.MULTIPLE) |
||
158 | self.listbox_outgoing.grid(row=0, column=0, columnspan=3, **kw_gsp) |
||
159 | scroll_outgoing = ttk.Scrollbar(frame, orient=tk.VERTICAL, command=self.listbox_outgoing.yview) |
||
160 | self.listbox_outgoing.configure(yscrollcommand=scroll_outgoing.set) |
||
161 | scroll_outgoing.grid(row=0, column=2, sticky=(tk.N, tk.E, tk.S)) |
||
162 | ttk.Button(frame, text="\u21BB", width=0, command=self.update).grid(row=1, column=0, sticky=tk.SW, ipadx=5, **kw_gp) |
||
163 | ttk.Button(frame, text="Remove Selected", command=self.do_remove).grid(row=1, column=1, sticky=tk.SW, ipadx=5, **kw_gp) |
||
164 | ttk.Button(frame, text="Share Songs...", command=self.do_share).grid(row=1, column=2, sticky=tk.SE, ipadx=5, **kw_gp) |
||
165 | |||
166 | return frame |
||
167 | |||
168 | def separator(master): |
||
169 | """Widget to separate frames.""" |
||
170 | return ttk.Separator(master) |
||
171 | |||
172 | # Place widgets |
||
173 | frame_settings(frame).grid(row=0, **kw_gs) |
||
174 | separator(frame).grid(row=1, padx=10, pady=5, **kw_gs) |
||
175 | frame_outgoing(frame).grid(row=2, **kw_gs) |
||
176 | separator(frame).grid(row=3, padx=10, pady=5, **kw_gs) |
||
177 | frame_incoming(frame).grid(row=4, **kw_gs) |
||
178 | |||
179 | return frame |
||
180 | |||
181 | 1 | def browse_downloads(self): |
|
182 | """Browser for a new downloads directory.""" |
||
183 | path = filedialog.askdirectory() |
||
184 | logging.debug("path: {}".format(path)) |
||
185 | if path: |
||
186 | self.user.path_downloads = path |
||
187 | self.path_downloads.set(self.user.path_downloads) |
||
188 | |||
189 | 1 | def do_remove(self): |
|
190 | """Remove selected songs.""" |
||
191 | for index in (int(s) for s in self.listbox_outgoing.curselection()): |
||
192 | self.outgoing[index].ignore() |
||
193 | self.update() |
||
194 | |||
195 | 1 | def do_share(self): |
|
196 | """Share songs.""" |
||
197 | paths = filedialog.askopenfilenames() |
||
198 | if isinstance(paths, str): # http://bugs.python.org/issue5712 |
||
199 | paths = self.master.tk.splitlist(paths) |
||
200 | logging.debug("paths: {}".format(paths)) |
||
201 | for path in paths: |
||
202 | self.user.recommend(path) |
||
203 | self.update() |
||
204 | |||
205 | 1 | def do_ignore(self): |
|
206 | """Ignore selected songs.""" |
||
207 | for index in (int(s) for s in self.listbox_incoming.curselection()): |
||
208 | song = self.incoming[index] |
||
209 | song.ignore() |
||
210 | self.update() |
||
211 | |||
212 | 1 | def do_download(self): |
|
213 | """Download selected songs.""" |
||
214 | indicies = (int(s) for s in self.listbox_incoming.curselection()) |
||
215 | try: |
||
216 | for index in indicies: |
||
217 | song = self.incoming[index] |
||
218 | song.download(catch=False) |
||
219 | except IOError as exc: |
||
220 | self.show_error_from_exception(exc, "Download Error") |
||
221 | self.update() |
||
222 | |||
223 | 1 | def update(self): |
|
224 | """Update the list of outgoing and incoming songs.""" |
||
225 | |||
226 | # Cleanup outgoing songs |
||
227 | self.user.cleanup() |
||
228 | |||
229 | # Update outgoing songs list |
||
230 | logging.info("updating outgoing songs...") |
||
231 | self.outgoing = list(self.user.outgoing) |
||
232 | self.listbox_outgoing.delete(0, tk.END) |
||
233 | for song in self.outgoing: |
||
234 | self.listbox_outgoing.insert(tk.END, song.out_string) |
||
235 | # Update incoming songs list |
||
236 | |||
237 | logging.info("updating incoming songs...") |
||
238 | self.incoming = list(self.user.incoming) |
||
239 | self.listbox_incoming.delete(0, tk.END) |
||
240 | for song in self.incoming: |
||
241 | self.listbox_incoming.insert(tk.END, song.in_string) |
||
242 | |||
243 | 1 | @staticmethod |
|
244 | 1 | def show_error_from_exception(exception, title="Error"): |
|
245 | """Convert an exception to an error dialog.""" |
||
246 | message = str(exception) |
||
247 | message = message[0].upper() + message[1:] |
||
248 | if ": " in message: |
||
249 | message.replace(": ", ":\n\n") |
||
250 | else: |
||
251 | message += "." |
||
252 | messagebox.showerror(title, message) |
||
253 | |||
254 | |||
255 | 1 | def main(args=None): |
|
256 | """Process command-line arguments and run the program.""" |
||
257 | |||
258 | # Main parser |
||
259 | 1 | parser = argparse.ArgumentParser(prog=GUI, description=__doc__, **SHARED) |
|
260 | 1 | parser.add_argument('--home', metavar='PATH', help="path to home directory") |
|
261 | # Hidden argument to override the root sharing directory path |
||
262 | 1 | parser.add_argument('--root', metavar="PATH", help=argparse.SUPPRESS) |
|
263 | # Hidden argument to run the program as a different user |
||
264 | 1 | parser.add_argument('--test', metavar='"First Last"', |
|
265 | help=argparse.SUPPRESS) |
||
266 | |||
267 | # Parse arguments |
||
268 | 1 | args = parser.parse_args(args=args) |
|
269 | |||
270 | # Configure logging |
||
271 | 1 | _configure_logging(args.verbose) |
|
272 | |||
273 | # Run the program |
||
274 | 1 | try: |
|
275 | 1 | success = run(args) |
|
276 | except KeyboardInterrupt: |
||
277 | logging.debug("program manually closed") |
||
278 | else: |
||
279 | 1 | if success: |
|
280 | 1 | logging.debug("program exited") |
|
281 | else: |
||
282 | logging.debug("program exited with error") |
||
283 | sys.exit(1) |
||
284 | |||
285 | |||
286 | 1 | def _configure_logging(verbosity=0): |
|
287 | """Configure logging using the provided verbosity level (0+).""" |
||
288 | |||
289 | # Configure the logging level and format |
||
290 | 1 | if verbosity == 0: |
|
291 | 1 | level = settings.VERBOSE_LOGGING_LEVEL |
|
292 | 1 | default_format = settings.DEFAULT_LOGGING_FORMAT |
|
293 | 1 | verbose_format = settings.VERBOSE_LOGGING_FORMAT |
|
294 | else: |
||
295 | 1 | level = settings.VERBOSE2_LOGGING_LEVEL |
|
296 | 1 | default_format = verbose_format = settings.VERBOSE_LOGGING_FORMAT |
|
297 | |||
298 | # Set a custom formatter |
||
299 | 1 | logging.basicConfig(level=level) |
|
300 | 1 | formatter = WarningFormatter(default_format, verbose_format) |
|
301 | 1 | logging.root.handlers[0].setFormatter(formatter) |
|
302 | |||
303 | |||
304 | 1 | def run(args): |
|
305 | """Start the GUI.""" |
||
306 | |||
307 | # Exit if tkinter is not available |
||
308 | 1 | if isinstance(tk, Mock) or isinstance(ttk, Mock): |
|
309 | 1 | logging.error("tkinter is not available") |
|
310 | 1 | return False |
|
311 | |||
312 | root = tk.Tk() |
||
313 | root.title("{} (v{})".format(GUI, __version__)) |
||
314 | root.minsize(500, 500) |
||
315 | |||
316 | # Map the Mac 'command' key to 'control' |
||
317 | if sys.platform == 'darwin': |
||
318 | root.bind_class('Listbox', '<Command-Button-1>', |
||
319 | root.bind_class('Listbox', '<Control-Button-1>')) |
||
320 | |||
321 | # Temporarily hide the window for other dialogs |
||
322 | root.withdraw() |
||
323 | |||
324 | # Start the application |
||
325 | try: |
||
326 | app = Application(master=root, home=args.home, |
||
327 | root=args.root, name=args.test) |
||
328 | if _LAUNCH: |
||
329 | app.mainloop() |
||
330 | except Exception as e: # pylint: disable=broad-except |
||
331 | Application.show_error_from_exception(e) |
||
332 | return False |
||
333 | |||
334 | return True |
||
335 | |||
336 | |||
337 | if __name__ == '__main__': # pragma: no cover (manual test) |
||
338 | main() |
||
339 |