Skip to content

Commit b6bce71

Browse files
committed
Add new link:… format key to FormatCodes
1 parent 87ff07d commit b6bce71

4 files changed

Lines changed: 91 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
* Fixed a small bug in `ProgressBar`, where it would only overwrite and not actually clear the previous line.
2828
* Added a new constant `ANSI.COLOR_VARIANTS_MAP`, which contains all possible color variants that can be used in formatting.
2929
* Made it possible to also pass console default colors to `title_bg_color` in `Console.log()`, instead of only custom RGBA or HEXA colors.
30+
* Added a new format key `link:…` to `FormatCodes`, which allows you to create hyperlinks in the console output with the syntax `[link:URL](display text)`.
3031

3132
**BREAKING CHANGES:**
3233
* All `Console` methods that allow console default colors as input for their color params, now actually validate the given color, raising an error if it's not valid.

src/xulbux/base/consts.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ def seq(cls, placeholders: int = 1, /) -> FormattableString:
9797
SEQ_BG_COLOR: Final[FormattableString] = CHAR + START + "48" + SEP + "2" + SEP + "{}" + SEP + "{}" + SEP + "{}" + END
9898
"""ANSI escape sequence with three placeholders for setting the RGB background color."""
9999

100+
SEQ_LINK_OPEN: Final[FormattableString] = CHAR + "]8;;{}" + CHAR + "\\"
101+
"""OSC 8 hyperlink opening sequence with a placeholder for the URL."""
102+
SEQ_LINK_CLOSE: Final[str] = CHAR + "]8;;" + CHAR + "\\"
103+
"""OSC 8 hyperlink closing sequence."""
104+
100105
COLOR_MAP: Final[set[str]] = {
101106
"black",
102107
"red",

src/xulbux/format_codes.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,12 @@
129129
- Total reset:
130130
This will reset all previously applied formatting codes.
131131
- `[_]`
132+
- Hyperlinks:
133+
Create a clickable hyperlink using the `link:` prefix followed by any URL.
134+
Auto-reset braces are required to define the visible, clickable text.
135+
Examples:
136+
- `[link:file:///C:/path/to/file.txt](open file)`
137+
- `[link:https://example.com|br:blue](click here)`
132138
133139
------------------------------------------------------------------------------------------------------------------------------------
134140
#### Additional Formatting Codes when a `default_color` is set
@@ -192,7 +198,8 @@
192198
_PATTERNS = LazyRegex(
193199
star_reset=r"\[\s*([^]_]*?)\s*\*\s*([^]_]*?)\]",
194200
star_reset_inside=r"([^|]*?)\s*\*\s*([^|]*)",
195-
ansi_seq=ANSI.CHAR + r"(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])",
201+
ansi_seq=ANSI.CHAR + r"(?:\].*?(?:\x1b\\|\x07)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])",
202+
link=r"(?i)^\s*link\s*:\s*(.+?)\s*$",
196203
formatting=(
197204
Regex.brackets("[", "]", is_group=True, ignore_in_strings=False) + r"(?:([/\\]?)"
198205
+ Regex.brackets("(", ")", is_group=True, strip_spaces=False, ignore_in_strings=False) + r")?"
@@ -633,8 +640,15 @@ def __call__(self, match: _rx.Match[str], /) -> str:
633640
else:
634641
_formats = _PATTERNS.star_reset_inside.sub(r"\1_\2", formats)
635642

636-
if all(self.cls._get_replacement(format_key, self.default_color) != format_key
637-
for format_key in self.cls._formats_to_keys(_formats)):
643+
has_link = False
644+
has_invalid_key = False
645+
for format_key in self.cls._formats_to_keys(_formats):
646+
if _PATTERNS.link.match(format_key):
647+
has_link = True
648+
elif self.cls._get_replacement(format_key, self.default_color) == format_key:
649+
has_invalid_key = True
650+
651+
if has_link or not has_invalid_key:
638652
# ESCAPE THE FORMATTING CODE
639653
escaped = f"[{self.escape_char}{formats}]"
640654
if auto_reset_txt:
@@ -702,14 +716,48 @@ def __call__(self, match: _rx.Match[str], /) -> str:
702716
if self.formats_escaped:
703717
self.original_formats = self.formats = _PATTERNS.escape_char.sub(r"\1", self.formats)
704718

719+
# HANDLE HYPERLINK FORMAT
720+
all_keys = self.cls._formats_to_keys(self.formats)
721+
if (result := self.handle_link(match, all_keys)) is not None:
722+
return result
723+
705724
self.process_formats_and_auto_reset()
706725

726+
# IF THERE ARE NO FORMATS OR ALL FORMATS ARE INVALID, RETURN THE ORIGINAL STRING
707727
if not self.formats:
708728
return match.group(0)
709729

710730
self.convert_to_ansi()
711731
return self.build_output(match)
712732

733+
def handle_link(self, match: _rx.Match[str], all_keys: list[str], /) -> Optional[str]:
734+
"""Handle a hyperlink format code, returning the OSC 8 sequence or None if not a link."""
735+
link_key = next((k for k in all_keys if _PATTERNS.link.match(k)), None)
736+
737+
if link_key is None:
738+
return None
739+
if self.auto_reset_txt is None:
740+
return match.group(0) # LINK WITHOUT DISPLAY BRACES IS INVALID
741+
if self.formats_escaped:
742+
return f"[{self.original_formats}]({self.auto_reset_txt})"
743+
744+
link_url = _PATTERNS.link.match(link_key).group(1) # type: ignore[union-attr]
745+
display = self.auto_reset_txt
746+
747+
if other_keys := [k for k in all_keys if k != link_key]:
748+
# APPLY REMAINING FORMAT CODES TO DISPLAY TEXT WITH AUTO-RESET
749+
display = "[{}]({})".format("|".join(other_keys), display)
750+
if other_keys or ("[" in display and "]" in display):
751+
display = self.cls.to_ansi(
752+
display,
753+
self.default_color,
754+
self.brightness_steps,
755+
_default_start=False,
756+
_validate_default=False,
757+
)
758+
759+
return ANSI.SEQ_LINK_OPEN.format(link_url) + display + ANSI.SEQ_LINK_CLOSE
760+
713761
def process_formats_and_auto_reset(self) -> None:
714762
"""Process nested formatting in both formats and auto-reset text."""
715763
# PROCESS AUTO-RESET TEXT IF IT CONTAINS NESTED FORMATTING

tests/test_format_codes.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,40 @@ def test_escape():
8080
assert FormatCodes.escape("[--]Hello", default_color="#FFF") == "[/--]Hello"
8181

8282

83+
def test_hyperlinks():
84+
url = "https://example.com"
85+
file_url = "file:///C:/path/to/file.txt"
86+
link_open = ANSI.SEQ_LINK_OPEN.format(url)
87+
link_open_file = ANSI.SEQ_LINK_OPEN.format(file_url)
88+
link_close = ANSI.SEQ_LINK_CLOSE
89+
90+
# BASIC LINK
91+
assert FormatCodes.to_ansi(f"[link:{url}](click here)") == f"{link_open}click here{link_close}"
92+
93+
# FILE URL
94+
assert FormatCodes.to_ansi(f"[link:{file_url}](open file)") == f"{link_open_file}open file{link_close}"
95+
96+
# LINK WITH NESTED FORMATTING IN DISPLAY TEXT
97+
assert FormatCodes.to_ansi(f"[link:{url}]([b](bold link))") == f"{link_open}{bold}bold link{reset_bold}{link_close}"
98+
99+
# LINK COMBINED WITH OTHER FORMAT KEYS
100+
assert FormatCodes.to_ansi(f"[link:{url}|b](click here)") == f"{link_open}{bold}click here{reset_bold}{link_close}"
101+
bright_blue = f"{ANSI.CHAR}{ANSI.START}{ANSI.CODES_MAP['br:blue']}{ANSI.END}"
102+
assert FormatCodes.to_ansi(f"[link:{url}|br:blue](click here)"
103+
) == (f"{link_open}{bright_blue}click here{reset_color}{link_close}")
104+
105+
# LINK WITHOUT DISPLAY BRACES IS INVALID (LEFT AS-IS)
106+
assert FormatCodes.to_ansi(f"[link:{url}]") == f"[link:{url}]"
107+
108+
# ESCAPE: LINK SHOULD BE ESCAPED
109+
assert FormatCodes.escape(f"[link:{url}](click here)") == f"[/link:{url}](click here)"
110+
assert FormatCodes.escape(f"[link:{url}|b](click here)") == f"[/link:{url}|b](click here)"
111+
112+
# REMOVE: OSC SEQUENCES FROM LINK SHOULD BE STRIPPED, LEAVING ONLY DISPLAY TEXT
113+
assert FormatCodes.remove(f"[link:{url}](click here)") == "click here"
114+
assert FormatCodes.remove_ansi(f"{link_open}click here{link_close}") == "click here"
115+
116+
83117
def test_remove_ansi():
84118
ansi_string = f"{bold}Hello {orange}World!{reset}"
85119
clean_string = "Hello World!"

0 commit comments

Comments
 (0)