Skip to content

Commit 5ece492

Browse files
committed
refactor ui
1 parent b25bc8d commit 5ece492

24 files changed

+1930
-1652
lines changed

beets/ui/__init__.py

Lines changed: 62 additions & 1469 deletions
Large diffs are not rendered by default.

beets/ui/_common.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
class UserError(Exception):
2+
"""UI exception. Commands should throw this in order to display
3+
nonrecoverable errors to the user.
4+
"""

beets/ui/colors.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import re
5+
from difflib import SequenceMatcher
6+
from functools import cache
7+
from itertools import chain
8+
from typing import Final, Literal
9+
10+
from beets import config, util
11+
from beets.ui._common import UserError
12+
13+
# Colorization.
14+
15+
# ANSI terminal colorization code heavily inspired by pygments:
16+
# https://bitbucket.org/birkenfeld/pygments-main/src/default/pygments/console.py
17+
# (pygments is by Tim Hatch, Armin Ronacher, et al.)
18+
19+
COLOR_ESCAPE: Final = "\x1b"
20+
LEGACY_COLORS: Final = {
21+
"black": ["black"],
22+
"darkred": ["red"],
23+
"darkgreen": ["green"],
24+
"brown": ["yellow"],
25+
"darkyellow": ["yellow"],
26+
"darkblue": ["blue"],
27+
"purple": ["magenta"],
28+
"darkmagenta": ["magenta"],
29+
"teal": ["cyan"],
30+
"darkcyan": ["cyan"],
31+
"lightgray": ["white"],
32+
"darkgray": ["bold", "black"],
33+
"red": ["bold", "red"],
34+
"green": ["bold", "green"],
35+
"yellow": ["bold", "yellow"],
36+
"blue": ["bold", "blue"],
37+
"fuchsia": ["bold", "magenta"],
38+
"magenta": ["bold", "magenta"],
39+
"turquoise": ["bold", "cyan"],
40+
"cyan": ["bold", "cyan"],
41+
"white": ["bold", "white"],
42+
}
43+
# All ANSI Colors.
44+
CODE_BY_COLOR: Final = {
45+
# Styles.
46+
"normal": 0,
47+
"bold": 1,
48+
"faint": 2,
49+
# "italic": 3,
50+
"underline": 4,
51+
# "blink_slow": 5,
52+
# "blink_rapid": 6,
53+
"inverse": 7,
54+
# "conceal": 8,
55+
# "crossed_out": 9
56+
# Text colors.
57+
"black": 30,
58+
"red": 31,
59+
"green": 32,
60+
"yellow": 33,
61+
"blue": 34,
62+
"magenta": 35,
63+
"cyan": 36,
64+
"white": 37,
65+
# Background colors.
66+
"bg_black": 40,
67+
"bg_red": 41,
68+
"bg_green": 42,
69+
"bg_yellow": 43,
70+
"bg_blue": 44,
71+
"bg_magenta": 45,
72+
"bg_cyan": 46,
73+
"bg_white": 47,
74+
}
75+
RESET_COLOR: Final = f"{COLOR_ESCAPE}[39;49;00m"
76+
# Precompile common ANSI-escape regex patterns
77+
ANSI_CODE_REGEX: Final = re.compile(rf"({COLOR_ESCAPE}\[[;0-9]*m)")
78+
ESC_TEXT_REGEX: Final = re.compile(
79+
rf"""(?P<pretext>[^{COLOR_ESCAPE}]*)
80+
(?P<esc>(?:{ANSI_CODE_REGEX.pattern})+)
81+
(?P<text>[^{COLOR_ESCAPE}]+)(?P<reset>{re.escape(RESET_COLOR)})
82+
(?P<posttext>[^{COLOR_ESCAPE}]*)""",
83+
re.VERBOSE,
84+
)
85+
ColorName = Literal[
86+
"text_success",
87+
"text_warning",
88+
"text_error",
89+
"text_highlight",
90+
"text_highlight_minor",
91+
"action_default",
92+
"action",
93+
# New Colors
94+
"text_faint",
95+
"import_path",
96+
"import_path_items",
97+
"action_description",
98+
"changed",
99+
"text_diff_added",
100+
"text_diff_removed",
101+
]
102+
103+
104+
@cache
105+
def get_color_config() -> dict[ColorName, str]:
106+
"""Parse and validate color configuration, converting names to ANSI codes.
107+
108+
Processes the UI color configuration, handling both new list format and
109+
legacy single-color format. Validates all color names against known codes
110+
and raises an error for any invalid entries.
111+
"""
112+
colors_by_color_name: dict[ColorName, list[str]] = {
113+
k: (v if isinstance(v, list) else LEGACY_COLORS.get(v, [v]))
114+
for k, v in config["ui"]["colors"].flatten().items()
115+
}
116+
117+
invalid_colors: set[str]
118+
if invalid_colors := (
119+
set(chain.from_iterable(colors_by_color_name.values()))
120+
- CODE_BY_COLOR.keys()
121+
):
122+
raise UserError(
123+
f"Invalid color(s) in configuration: {', '.join(invalid_colors)}"
124+
)
125+
126+
return {
127+
n: ";".join(str(CODE_BY_COLOR[c]) for c in colors)
128+
for n, colors in colors_by_color_name.items()
129+
}
130+
131+
132+
def colorize(color_name: ColorName, text: str) -> str:
133+
"""Apply ANSI color formatting to text based on configuration settings.
134+
135+
Returns colored text when color output is enabled and NO_COLOR environment
136+
variable is not set, otherwise returns plain text unchanged.
137+
"""
138+
if config["ui"]["color"] and "NO_COLOR" not in os.environ:
139+
color_code: str = get_color_config()[color_name]
140+
return f"{COLOR_ESCAPE}[{color_code}m{text}{RESET_COLOR}"
141+
142+
return text
143+
144+
145+
def uncolorize(colored_text: str) -> str:
146+
"""Remove colors from a string."""
147+
# Define a regular expression to match ANSI codes.
148+
# See: http://stackoverflow.com/a/2187024/1382707
149+
# Explanation of regular expression:
150+
# \x1b - matches ESC character
151+
# \[ - matches opening square bracket
152+
# [;\d]* - matches a sequence consisting of one or more digits or
153+
# semicola
154+
# [A-Za-z] - matches a letter
155+
return ANSI_CODE_REGEX.sub("", colored_text)
156+
157+
158+
def color_split(colored_text: str, index: int):
159+
length: int = 0
160+
pre_split: str = ""
161+
post_split: str = ""
162+
found_color_code: str | None = None
163+
found_split: bool = False
164+
part: str
165+
for part in ANSI_CODE_REGEX.split(colored_text) or ():
166+
# Count how many real letters we have passed
167+
length += color_len(part)
168+
if found_split:
169+
post_split += part
170+
else:
171+
if ANSI_CODE_REGEX.match(part):
172+
# This is a color code
173+
if part == RESET_COLOR:
174+
found_color_code = None
175+
else:
176+
found_color_code = part
177+
pre_split += part
178+
else:
179+
if index < length:
180+
# Found part with our split in.
181+
split_index: int = index - (length - color_len(part))
182+
found_split = True
183+
if found_color_code:
184+
pre_split += f"{part[:split_index]}{RESET_COLOR}"
185+
post_split += f"{found_color_code}{part[split_index:]}"
186+
else:
187+
pre_split += part[:split_index]
188+
post_split += part[split_index:]
189+
else:
190+
# Not found, add this part to the pre split
191+
pre_split += part
192+
return pre_split, post_split
193+
194+
195+
def color_len(colored_text: str) -> int:
196+
"""Measure the length of a string while excluding ANSI codes from the
197+
measurement. The standard `len(my_string)` method also counts ANSI codes
198+
to the string length, which is counterproductive when layouting a
199+
Terminal interface.
200+
"""
201+
# Return the length of the uncolored string.
202+
return len(uncolorize(colored_text))
203+
204+
205+
def _colordiff(a: object, b: object) -> tuple[str, str]:
206+
"""Given two values, return the same pair of strings except with
207+
their differences highlighted in the specified color. Strings are
208+
highlighted intelligently to show differences; other values are
209+
stringified and highlighted in their entirety.
210+
"""
211+
# First, convert paths to readable format
212+
value: object
213+
for value in a, b:
214+
if isinstance(value, bytes):
215+
# A path field.
216+
value = util.displayable_path(value)
217+
218+
if not isinstance(a, str) or not isinstance(b, str):
219+
# Non-strings: use ordinary equality.
220+
if a == b:
221+
return str(a), str(b)
222+
else:
223+
return (
224+
colorize("text_diff_removed", str(a)),
225+
colorize("text_diff_added", str(b)),
226+
)
227+
228+
before: str = ""
229+
after: str = ""
230+
231+
op: str
232+
a_start: int
233+
a_end: int
234+
b_start: int
235+
b_end: int
236+
matcher: SequenceMatcher[str] = SequenceMatcher(lambda x: False, a, b)
237+
for op, a_start, a_end, b_start, b_end in matcher.get_opcodes():
238+
before_part: str
239+
after_part: str
240+
before_part, after_part = a[a_start:a_end], b[b_start:b_end]
241+
if op in {"delete", "replace"}:
242+
before_part = colorize("text_diff_removed", before_part)
243+
if op in {"insert", "replace"}:
244+
after_part = colorize("text_diff_added", after_part)
245+
246+
before += before_part
247+
after += after_part
248+
249+
return before, after
250+
251+
252+
def colordiff(a: object, b: object) -> tuple[str, str]:
253+
"""Colorize differences between two values if color is enabled.
254+
(Like _colordiff but conditional.)
255+
"""
256+
if config["ui"]["color"]:
257+
return _colordiff(a, b)
258+
else:
259+
return str(a), str(b)

beets/ui/commands/__init__.py

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,7 @@
1818

1919
from beets.util import deprecate_imports
2020

21-
from .completion import completion_cmd
22-
from .config import config_cmd
23-
from .fields import fields_cmd
24-
from .help import HelpCommand
25-
from .import_ import import_cmd
26-
from .list import list_cmd
27-
from .modify import modify_cmd
28-
from .move import move_cmd
29-
from .remove import remove_cmd
30-
from .stats import stats_cmd
31-
from .update import update_cmd
32-
from .version import version_cmd
33-
from .write import write_cmd
21+
from .default_commands import default_commands
3422

3523

3624
def __getattr__(name: str):
@@ -47,23 +35,4 @@ def __getattr__(name: str):
4735
)
4836

4937

50-
# The list of default subcommands. This is populated with Subcommand
51-
# objects that can be fed to a SubcommandsOptionParser.
52-
default_commands = [
53-
fields_cmd,
54-
HelpCommand(),
55-
import_cmd,
56-
list_cmd,
57-
update_cmd,
58-
remove_cmd,
59-
stats_cmd,
60-
version_cmd,
61-
modify_cmd,
62-
move_cmd,
63-
write_cmd,
64-
config_cmd,
65-
completion_cmd,
66-
]
67-
68-
6938
__all__ = ["default_commands"]

beets/ui/commands/_utils.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import os
44

5-
from beets import ui
5+
from beets.ui._common import UserError
66
from beets.util import displayable_path, normpath, syspath
77

88

@@ -25,9 +25,9 @@ def do_query(lib, query, album, also_items=True):
2525
items = list(lib.items(query))
2626

2727
if album and not albums:
28-
raise ui.UserError("No matching albums found.")
28+
raise UserError("No matching albums found.")
2929
elif not album and not items:
30-
raise ui.UserError("No matching items found.")
30+
raise UserError("No matching items found.")
3131

3232
return items, albums
3333

@@ -58,10 +58,10 @@ def parse_logfiles(logfiles):
5858
try:
5959
yield from paths_from_logfile(syspath(normpath(logfile)))
6060
except ValueError as err:
61-
raise ui.UserError(
61+
raise UserError(
6262
f"malformed logfile {displayable_path(logfile)}: {err}"
6363
) from err
6464
except OSError as err:
65-
raise ui.UserError(
65+
raise UserError(
6666
f"unreadable logfile {displayable_path(logfile)}: {err}"
6767
) from err

beets/ui/commands/completion.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
import os
44
import re
55

6-
from beets import library, logging, plugins, ui
6+
from beets import library, logging, plugins
7+
from beets.ui.core import Subcommand, print_
78
from beets.util import syspath
89

910
# Global logger.
@@ -14,15 +15,15 @@ def print_completion(*args):
1415
from beets.ui.commands import default_commands
1516

1617
for line in completion_script(default_commands + plugins.commands()):
17-
ui.print_(line, end="")
18+
print_(line, end="")
1819
if not any(os.path.isfile(syspath(p)) for p in BASH_COMPLETION_PATHS):
1920
log.warning(
2021
"Warning: Unable to find the bash-completion package. "
2122
"Command line completion might not work."
2223
)
2324

2425

25-
completion_cmd = ui.Subcommand(
26+
completion_cmd = Subcommand(
2627
"completion",
2728
help="print shell script that provides command line completion",
2829
)

0 commit comments

Comments
 (0)