bug fixes, console themes, help interface
This commit is contained in:
parent
335d67c9d2
commit
b7ec4259a6
47
groove/console.py
Normal file
47
groove/console.py
Normal 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')
|
|
@ -3,12 +3,17 @@ import os
|
|||
import subprocess
|
||||
import yaml
|
||||
|
||||
from yaml.scanner import ScannerError
|
||||
from groove.exceptions import PlaylistValidationError
|
||||
|
||||
|
||||
from tempfile import NamedTemporaryFile
|
||||
|
||||
|
||||
EDITOR_TEMPLATE = """
|
||||
{name}:
|
||||
description: {description}
|
||||
description: |
|
||||
{description}
|
||||
entries:
|
||||
{entries}
|
||||
|
||||
|
@ -60,10 +65,21 @@ class PlaylistEditor:
|
|||
return self._path
|
||||
|
||||
def edit(self, playlist):
|
||||
with self.path as fh:
|
||||
fh.write(playlist.as_yaml.encode())
|
||||
subprocess.check_call([os.environ['EDITOR'], self.path.name])
|
||||
edits = self.read()
|
||||
try:
|
||||
with self.path as fh:
|
||||
fh.write(playlist.as_yaml.encode())
|
||||
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()
|
||||
return edits
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import os
|
||||
|
||||
from textwrap import indent
|
||||
from typing import Union, List
|
||||
|
||||
from groove import db
|
||||
|
@ -13,6 +14,11 @@ from sqlalchemy.orm.session import Session
|
|||
from sqlalchemy.engine.row import Row
|
||||
from sqlalchemy.exc import NoResultFound, MultipleResultsFound
|
||||
|
||||
from rich.table import Table, Column
|
||||
from rich import box
|
||||
|
||||
from yaml.scanner import ScannerError
|
||||
|
||||
|
||||
class Playlist:
|
||||
"""
|
||||
|
@ -135,12 +141,54 @@ class Playlist:
|
|||
def as_string(self) -> str:
|
||||
text = self.info
|
||||
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
|
||||
|
||||
@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
|
||||
def as_yaml(self) -> str:
|
||||
template_vars = self.as_dict
|
||||
template_vars['description'] = indent(template_vars['description'], prefix=' ')
|
||||
template_vars['entries'] = ''
|
||||
for entry in self.entries:
|
||||
template_vars['entries'] += f' - "{entry.artist}": "{entry.title}"\n'
|
||||
|
@ -200,10 +248,23 @@ class Playlist:
|
|||
edits = self.editor.edit(self)
|
||||
if not edits:
|
||||
return
|
||||
new = Playlist.from_yaml(edits, self.session)
|
||||
if new == self:
|
||||
logging.debug("No changes detected.")
|
||||
return
|
||||
try:
|
||||
new = Playlist.from_yaml(edits, self.session)
|
||||
if new == self:
|
||||
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.")
|
||||
self._slug = new.slug
|
||||
self._name = new.name.strip()
|
||||
|
@ -345,10 +406,13 @@ class Playlist:
|
|||
session=session,
|
||||
create_ok=create_ok
|
||||
)
|
||||
pl._entries = list(pl._get_tracks_by_artist_and_title(entries=[
|
||||
list(entry.items())[0] for entry in source[name]['entries']
|
||||
]))
|
||||
except (IndexError, KeyError):
|
||||
if not source[name]['entries']:
|
||||
pl._entries = []
|
||||
else:
|
||||
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.")
|
||||
return pl
|
||||
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
from prompt_toolkit import prompt
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
from prompt_toolkit import print_formatted_text, HTML
|
||||
from groove.console import Console
|
||||
|
||||
|
||||
|
||||
class BasePrompt(Completer):
|
||||
|
||||
def __init__(self, manager=None, parent=None):
|
||||
def __init__(self, manager=None, console=None, parent=None):
|
||||
super(BasePrompt, self).__init__()
|
||||
|
||||
if (not manager and not parent): # pragma: no cover
|
||||
|
@ -14,6 +17,14 @@ class BasePrompt(Completer):
|
|||
self._values = []
|
||||
self._parent = parent
|
||||
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
|
||||
def usage(self):
|
||||
|
@ -56,10 +67,16 @@ class BasePrompt(Completer):
|
|||
except NotImplementedError:
|
||||
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=''):
|
||||
while True:
|
||||
if not cmd:
|
||||
cmd = prompt(f'{self.prompt} ', completer=self)
|
||||
cmd = self.console.prompt(self.prompt, completer=self)
|
||||
if not cmd:
|
||||
return
|
||||
cmd, *parts = cmd.split()
|
||||
|
|
|
@ -11,9 +11,12 @@ class CommandPrompt(BasePrompt):
|
|||
def __init__(self, manager):
|
||||
super().__init__(manager=manager)
|
||||
self._playlist = None
|
||||
self._prompt = "Groove on Demand interactive shell. Try 'help' for help.\ngroove>"
|
||||
self._completer = None
|
||||
self._commands = None
|
||||
self._prompt = [
|
||||
"[help]Groove on Demand interactive shell. Try 'help' for help.[/help]",
|
||||
"groove"
|
||||
]
|
||||
|
||||
@property
|
||||
def playlist(self):
|
||||
|
|
|
@ -1,23 +1,67 @@
|
|||
from .base import BasePrompt
|
||||
|
||||
from prompt_toolkit import prompt
|
||||
from rich import print
|
||||
import os
|
||||
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from textwrap import dedent, wrap
|
||||
|
||||
from groove import db
|
||||
from groove.exceptions import PlaylistValidationError
|
||||
|
||||
|
||||
class _playlist(BasePrompt):
|
||||
"""
|
||||
"""
|
||||
|
||||
def __init__(self, parent, manager=None):
|
||||
super().__init__(manager=manager, parent=parent)
|
||||
self._parent = parent
|
||||
self._prompt = ''
|
||||
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
|
||||
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
|
||||
def values(self):
|
||||
|
@ -27,32 +71,41 @@ class _playlist(BasePrompt):
|
|||
def commands(self):
|
||||
if not self._commands:
|
||||
self._commands = {
|
||||
'show': self.show,
|
||||
'delete': self.delete,
|
||||
'add': self.add,
|
||||
'show': self.show,
|
||||
'edit': self.edit,
|
||||
'help': self.help
|
||||
}
|
||||
return self._commands
|
||||
|
||||
def process(self, cmd, *parts):
|
||||
if cmd in self.commands:
|
||||
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
|
||||
|
||||
def show(self, parts):
|
||||
print(self.parent.playlist)
|
||||
def edit(self, *parts):
|
||||
try:
|
||||
self.parent.playlist.edit()
|
||||
except PlaylistValidationError as e:
|
||||
self.parent.console.error(f"Changes were not saved: {e}")
|
||||
else:
|
||||
self.show()
|
||||
return True
|
||||
|
||||
def edit(self, parts):
|
||||
self.parent.playlist.edit()
|
||||
def show(self, *parts):
|
||||
self.parent.console.print(self.parent.playlist.as_richtext)
|
||||
return True
|
||||
|
||||
def add(self, parts):
|
||||
print("Add tracks one at a time by title. ENTER to finish.")
|
||||
def add(self, *parts):
|
||||
self.parent.console.print(
|
||||
"Add tracks one at a time by title. Hit Enter to finish."
|
||||
)
|
||||
added = False
|
||||
while True:
|
||||
text = prompt(
|
||||
' ?',
|
||||
text = self.parent.console.prompt(
|
||||
[' ?'],
|
||||
completer=self.manager.fuzzy_table_completer(
|
||||
db.track,
|
||||
db.track.c.relpath,
|
||||
|
@ -61,8 +114,11 @@ class _playlist(BasePrompt):
|
|||
complete_in_thread=True, complete_while_typing=True
|
||||
)
|
||||
if not text:
|
||||
if added:
|
||||
self.show()
|
||||
return True
|
||||
self._add_track(text)
|
||||
added = True
|
||||
|
||||
def _add_track(self, text):
|
||||
sess = self.parent.manager.session
|
||||
|
@ -70,20 +126,25 @@ class _playlist(BasePrompt):
|
|||
track = sess.query(db.track).filter(db.track.c.relpath == text).one()
|
||||
self.parent.playlist.create_entries([track])
|
||||
except NoResultFound:
|
||||
print("No match for '{text}'")
|
||||
self.parent.console.error("No match for '{text}'")
|
||||
return
|
||||
return text
|
||||
|
||||
def delete(self, parts):
|
||||
res = prompt(
|
||||
'Type DELETE to permanently delete the playlist '
|
||||
f'"{self.parent.playlist.record.name}".\nDELETE {self.prompt}'
|
||||
)
|
||||
def delete(self, *parts):
|
||||
res = self.parent.console.prompt([
|
||||
f"[error]Type [b]DELETE[/b] to permanently delete the playlist "
|
||||
f'"{self.parent.playlist.record.name}".[/error]',
|
||||
f"[prompt]DELETE {self.parent.playlist.slug}[/prompt]",
|
||||
])
|
||||
if res != 'DELETE':
|
||||
print("Delete aborted. No changes have been made.")
|
||||
self.parent.console.error("Delete aborted. No changes have been made.")
|
||||
return True
|
||||
|
||||
self.parent.playlist.delete()
|
||||
print("Deleted the playlist.")
|
||||
self.parent.console.print("Deleted the playlist.")
|
||||
self.parent._playlist = None
|
||||
return False
|
||||
|
||||
def start(self):
|
||||
self.show()
|
||||
super().start()
|
||||
|
|
Loading…
Reference in New Issue
Block a user