bug fixes, console themes, help interface

This commit is contained in:
evilchili 2022-12-16 23:24:05 -08:00
parent 335d67c9d2
commit b7ec4259a6
6 changed files with 247 additions and 39 deletions

47
groove/console.py Normal file
View File

@ -0,0 +1,47 @@
import os
from pathlib import Path
from textwrap import dedent
from rich.console import Console as _Console
from rich.markdown import Markdown
from rich.theme import Theme
from prompt_toolkit import prompt as _toolkit_prompt
from prompt_toolkit.formatted_text import ANSI
from groove.path import theme
BASE_STYLE = {
'help': 'cyan',
'bright': 'white',
'repr.str': 'dim',
'repr.brace': 'dim',
'repr.url': 'blue',
}
class Console(_Console):
def __init__(self, *args, **kwargs):
if 'theme' not in kwargs:
theme_path = theme(os.environ['DEFAULT_THEME'])
kwargs['theme'] = Theme(BASE_STYLE).read(theme_path / Path('console.cfg'), inherit=False)
super().__init__(*args, **kwargs)
def prompt(self, lines, **kwargs):
for line in lines[:-1]:
super().print(line)
with self.capture() as capture:
super().print(f"[prompt bold]{lines[-1]}>[/] ", end='')
rendered = ANSI(capture.get())
return _toolkit_prompt(rendered, **kwargs)
def mdprint(self, txt, **kwargs):
self.print(Markdown(dedent(txt), justify='left'), **kwargs)
def print(self, txt, **kwargs):
super().print(txt, **kwargs)
def error(self, txt, **kwargs):
super().print(dedent(txt), style='error')

View File

@ -3,12 +3,17 @@ import os
import subprocess import subprocess
import yaml import yaml
from yaml.scanner import ScannerError
from groove.exceptions import PlaylistValidationError
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
EDITOR_TEMPLATE = """ EDITOR_TEMPLATE = """
{name}: {name}:
description: {description} description: |
{description}
entries: entries:
{entries} {entries}
@ -60,10 +65,21 @@ class PlaylistEditor:
return self._path return self._path
def edit(self, playlist): def edit(self, playlist):
with self.path as fh: try:
fh.write(playlist.as_yaml.encode()) with self.path as fh:
subprocess.check_call([os.environ['EDITOR'], self.path.name]) fh.write(playlist.as_yaml.encode())
edits = self.read() subprocess.check_call([os.environ['EDITOR'], self.path.name])
except (IOError, OSError, FileNotFoundError) as e:
logging.error(e)
raise RuntimeError("Could not invoke the editor! If the error persists, try enabling DEBUG mode.")
try:
edits = self.read()
except ScannerError:
raise PlaylistValidationError(
f"An error occurred when importing the playlist definition. This is "
f"typically the result of a YAML syntax error; you can inspect the "
f"source for errors at {self._path.name}."
)
self.cleanup() self.cleanup()
return edits return edits

View File

@ -1,6 +1,7 @@
import logging import logging
import os import os
from textwrap import indent
from typing import Union, List from typing import Union, List
from groove import db from groove import db
@ -13,6 +14,11 @@ from sqlalchemy.orm.session import Session
from sqlalchemy.engine.row import Row from sqlalchemy.engine.row import Row
from sqlalchemy.exc import NoResultFound, MultipleResultsFound from sqlalchemy.exc import NoResultFound, MultipleResultsFound
from rich.table import Table, Column
from rich import box
from yaml.scanner import ScannerError
class Playlist: class Playlist:
""" """
@ -135,12 +141,54 @@ class Playlist:
def as_string(self) -> str: def as_string(self) -> str:
text = self.info text = self.info
for (tracknum, entry) in enumerate(self.entries): for (tracknum, entry) in enumerate(self.entries):
text += f" - {tracknum+1} {entry.artist} - {entry.title}\n" text += f" {tracknum+1:-3d}. {entry.artist} - {entry.title}\n"
return text return text
@property
def as_richtext(self) -> str:
title = f"\n [b]:headphones: {self.name}[/b]"
if self.description:
title += f"\n [italic]{self.description}[/italic]\n"
params = dict(
box=box.HORIZONTALS,
title=title,
title_justify='left',
caption=f"[link]{self.url}[/link]",
caption_justify='right',
)
if os.environ['CONSOLE_THEMES']:
params.update(
header_style='on #001321',
title_style='on #001321',
border_style='on #001321',
row_styles=['on #001321'],
caption_style='on #001321',
style='on #001321',
)
width = os.environ.get('CONSOLE_WIDTH', 'auto')
if width == 'expand':
params['expand'] = True
elif width != 'auto':
params['width'] = int(width)
table = Table(
Column('#', justify='right', width=4),
Column('Artist', justify='left'),
Column('Title', justify='left'),
**params
)
for (num, entry) in enumerate(self.entries):
table.add_row(
f"[text]{num+1}[/text]",
f"[artist]{entry.artist}[/artist]",
f"[title]{entry.title}[/title]"
)
return table
@property @property
def as_yaml(self) -> str: def as_yaml(self) -> str:
template_vars = self.as_dict template_vars = self.as_dict
template_vars['description'] = indent(template_vars['description'], prefix=' ')
template_vars['entries'] = '' template_vars['entries'] = ''
for entry in self.entries: for entry in self.entries:
template_vars['entries'] += f' - "{entry.artist}": "{entry.title}"\n' template_vars['entries'] += f' - "{entry.artist}": "{entry.title}"\n'
@ -200,10 +248,23 @@ class Playlist:
edits = self.editor.edit(self) edits = self.editor.edit(self)
if not edits: if not edits:
return return
new = Playlist.from_yaml(edits, self.session) try:
if new == self: new = Playlist.from_yaml(edits, self.session)
logging.debug("No changes detected.") if new == self:
return logging.debug("No changes detected.")
return
except (TypeError, ScannerError) as e:
logging.error(e)
raise PlaylistValidationError(
"An error occurred reading the input file; this is typically "
"the result of an error in the YAML structure."
)
except TrackNotFoundError as e:
logging.error(e)
raise PlaylistValidationError(
"One or more of the specified tracks "
"did not exactly match an entry in the database."
)
logging.debug(f"Updating {self.slug} with new edits.") logging.debug(f"Updating {self.slug} with new edits.")
self._slug = new.slug self._slug = new.slug
self._name = new.name.strip() self._name = new.name.strip()
@ -345,10 +406,13 @@ class Playlist:
session=session, session=session,
create_ok=create_ok create_ok=create_ok
) )
pl._entries = list(pl._get_tracks_by_artist_and_title(entries=[ if not source[name]['entries']:
list(entry.items())[0] for entry in source[name]['entries'] pl._entries = []
])) else:
except (IndexError, KeyError): pl._entries = list(pl._get_tracks_by_artist_and_title(entries=[
list(entry.items())[0] for entry in source[name]['entries']
]))
except (IndexError, KeyError, AttributeError):
raise PlaylistValidationError("The specified source was not a valid playlist.") raise PlaylistValidationError("The specified source was not a valid playlist.")
return pl return pl

View File

@ -1,10 +1,13 @@
from prompt_toolkit import prompt from prompt_toolkit import prompt
from prompt_toolkit.completion import Completer, Completion from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit import print_formatted_text, HTML
from groove.console import Console
class BasePrompt(Completer): class BasePrompt(Completer):
def __init__(self, manager=None, parent=None): def __init__(self, manager=None, console=None, parent=None):
super(BasePrompt, self).__init__() super(BasePrompt, self).__init__()
if (not manager and not parent): # pragma: no cover if (not manager and not parent): # pragma: no cover
@ -14,6 +17,14 @@ class BasePrompt(Completer):
self._values = [] self._values = []
self._parent = parent self._parent = parent
self._manager = manager self._manager = manager
self._console = None
self._theme = None
@property
def console(self):
if not self._console:
self._console = Console(color_system='truecolor')
return self._console
@property @property
def usage(self): def usage(self):
@ -56,10 +67,16 @@ class BasePrompt(Completer):
except NotImplementedError: except NotImplementedError:
pass pass
def help(self, parts):
self.console.print(
getattr(self, parts[0]).__doc__ if parts else self.help_text
)
return True
def start(self, cmd=''): def start(self, cmd=''):
while True: while True:
if not cmd: if not cmd:
cmd = prompt(f'{self.prompt} ', completer=self) cmd = self.console.prompt(self.prompt, completer=self)
if not cmd: if not cmd:
return return
cmd, *parts = cmd.split() cmd, *parts = cmd.split()

View File

@ -11,9 +11,12 @@ class CommandPrompt(BasePrompt):
def __init__(self, manager): def __init__(self, manager):
super().__init__(manager=manager) super().__init__(manager=manager)
self._playlist = None self._playlist = None
self._prompt = "Groove on Demand interactive shell. Try 'help' for help.\ngroove>"
self._completer = None self._completer = None
self._commands = None self._commands = None
self._prompt = [
"[help]Groove on Demand interactive shell. Try 'help' for help.[/help]",
"groove"
]
@property @property
def playlist(self): def playlist(self):

View File

@ -1,23 +1,67 @@
from .base import BasePrompt from .base import BasePrompt
from prompt_toolkit import prompt import os
from rich import print
from sqlalchemy.exc import NoResultFound from sqlalchemy.exc import NoResultFound
from textwrap import dedent, wrap
from groove import db from groove import db
from groove.exceptions import PlaylistValidationError
class _playlist(BasePrompt): class _playlist(BasePrompt):
"""
"""
def __init__(self, parent, manager=None): def __init__(self, parent, manager=None):
super().__init__(manager=manager, parent=parent) super().__init__(manager=manager, parent=parent)
self._parent = parent self._parent = parent
self._prompt = ''
self._commands = None self._commands = None
@property
def help_text(self):
synopsis = (
f"You are currently editing the [b]{self.parent.playlist.name}[/b]"
f" playlist. From this prompt you can quickly append new tracks "
f"to the playlist. You can invoke your editor "
f"([link]{os.environ['EDITOR']}[/link]) to change the playlist "
f"name and description, or reorder or remove tracks. You can also "
f"delete the playlist."
)
try:
width = int(os.environ.get('CONSOLE_WIDTH', '80'))
except ValueError:
width = 80
synopsis = '\n '.join(wrap(synopsis, width=width))
return dedent(f"""
[title]WORKING WITH PLAYLISTS[/title]
{synopsis}
[title]USAGE[/title]
[link]playlist> COMMAND [ARG ..][/link]
[title]COMMANDS[/title]
[help]
[b]add[/b] Add one or more tracks to the playlist
[b]edit[/b] Open the playlist in the system editor
[b]show[/b] Display the complete playlist
[b]delete[/b] Delete the playlist
[b]help[/b] This message
Try 'help command' for command-specific help.[/help]
""")
@property @property
def prompt(self): def prompt(self):
return f"{self.parent.playlist}\n{self.parent.playlist.slug}> " return [
"",
"[help]Available commands: add, edit, show, delete, help. Hit Enter to return.[/help]",
f"[prompt]{self.parent.playlist.slug}[/prompt]",
]
@property @property
def values(self): def values(self):
@ -27,32 +71,41 @@ class _playlist(BasePrompt):
def commands(self): def commands(self):
if not self._commands: if not self._commands:
self._commands = { self._commands = {
'show': self.show,
'delete': self.delete, 'delete': self.delete,
'add': self.add, 'add': self.add,
'show': self.show,
'edit': self.edit, 'edit': self.edit,
'help': self.help
} }
return self._commands return self._commands
def process(self, cmd, *parts): def process(self, cmd, *parts):
if cmd in self.commands: if cmd in self.commands:
return True if self.commands[cmd](parts) else False return True if self.commands[cmd](parts) else False
print(f"Command not understood: {cmd}") self.parent.console.error(f"Command not understood: {cmd}")
return True return True
def show(self, parts): def edit(self, *parts):
print(self.parent.playlist) try:
self.parent.playlist.edit()
except PlaylistValidationError as e:
self.parent.console.error(f"Changes were not saved: {e}")
else:
self.show()
return True return True
def edit(self, parts): def show(self, *parts):
self.parent.playlist.edit() self.parent.console.print(self.parent.playlist.as_richtext)
return True return True
def add(self, parts): def add(self, *parts):
print("Add tracks one at a time by title. ENTER to finish.") self.parent.console.print(
"Add tracks one at a time by title. Hit Enter to finish."
)
added = False
while True: while True:
text = prompt( text = self.parent.console.prompt(
' ?', [' ?'],
completer=self.manager.fuzzy_table_completer( completer=self.manager.fuzzy_table_completer(
db.track, db.track,
db.track.c.relpath, db.track.c.relpath,
@ -61,8 +114,11 @@ class _playlist(BasePrompt):
complete_in_thread=True, complete_while_typing=True complete_in_thread=True, complete_while_typing=True
) )
if not text: if not text:
if added:
self.show()
return True return True
self._add_track(text) self._add_track(text)
added = True
def _add_track(self, text): def _add_track(self, text):
sess = self.parent.manager.session sess = self.parent.manager.session
@ -70,20 +126,25 @@ class _playlist(BasePrompt):
track = sess.query(db.track).filter(db.track.c.relpath == text).one() track = sess.query(db.track).filter(db.track.c.relpath == text).one()
self.parent.playlist.create_entries([track]) self.parent.playlist.create_entries([track])
except NoResultFound: except NoResultFound:
print("No match for '{text}'") self.parent.console.error("No match for '{text}'")
return return
return text return text
def delete(self, parts): def delete(self, *parts):
res = prompt( res = self.parent.console.prompt([
'Type DELETE to permanently delete the playlist ' f"[error]Type [b]DELETE[/b] to permanently delete the playlist "
f'"{self.parent.playlist.record.name}".\nDELETE {self.prompt}' f'"{self.parent.playlist.record.name}".[/error]',
) f"[prompt]DELETE {self.parent.playlist.slug}[/prompt]",
])
if res != 'DELETE': if res != 'DELETE':
print("Delete aborted. No changes have been made.") self.parent.console.error("Delete aborted. No changes have been made.")
return True return True
self.parent.playlist.delete() self.parent.playlist.delete()
print("Deleted the playlist.") self.parent.console.print("Deleted the playlist.")
self.parent._playlist = None self.parent._playlist = None
return False return False
def start(self):
self.show()
super().start()