From 607b4efa998e14e73235c3379d2ebd5a642a3e3c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Mon, 29 Jul 2024 22:34:22 +0100 Subject: [PATCH] Allowing themes to affect the syntax and URL highlighting --- src/posting/highlighters.py | 34 +++-- src/posting/posting.scss | 43 ++++++ src/posting/themes.py | 143 +++++++++++++++++- src/posting/widgets/input.py | 90 +++++++++++ .../widgets/request/method_selection.py | 7 - src/posting/widgets/request/url_bar.py | 54 ++----- src/posting/widgets/text_area.py | 18 ++- src/posting/widgets/variable_input.py | 14 ++ 8 files changed, 334 insertions(+), 69 deletions(-) diff --git a/src/posting/highlighters.py b/src/posting/highlighters.py index fcabc229..41b4b434 100644 --- a/src/posting/highlighters.py +++ b/src/posting/highlighters.py @@ -1,7 +1,9 @@ import re from rich.highlighter import Highlighter +from rich.style import Style from rich.text import Text from textual.widgets import Input +from posting.themes import UrlStyles, VariableStyles from posting.variables import ( find_variable_end, @@ -15,51 +17,55 @@ _URL_REGEX = re.compile(r"(?Phttps?)://(?P[^/]+)(?P/[^ ]*)?") -def highlight_url(text: Text) -> None: +def highlight_url(text: Text, styles: UrlStyles) -> None: for match in _URL_REGEX.finditer(text.plain): protocol_start, protocol_end = match.span("protocol") base_start, base_end = match.span("base") separator_start, separator_end = protocol_end, protocol_end + 3 - text.stylize("#818cf8", protocol_start, protocol_end) + text.stylize(styles.protocol or "#818cf8", protocol_start, protocol_end) text.stylize("dim", separator_start, separator_end) - text.stylize("#00C168", base_start, base_end) + text.stylize(styles.base or "#00C168", base_start, base_end) for index, char in enumerate(text.plain): if char == "/": text.stylize("dim b", index, index + 1) -class URLHighlighter(Highlighter): - def highlight(self, text: Text) -> None: - highlight_url(text) - - -def highlight_variables(text: Text) -> None: +def highlight_variables(text: Text, styles: VariableStyles) -> None: for match in find_variables(text.plain): variable_name, start, end = match if variable_name not in get_variables(): - text.stylize("dim", start, end) + text.stylize(Style.parse(styles.unresolved), start, end) else: - text.stylize("b green not dim", start, end) + text.stylize(Style.parse(styles.resolved), start, end) class VariableHighlighter(Highlighter): + def __init__(self, variable_styles: VariableStyles | None = None) -> None: + super().__init__() + self.variable_styles = variable_styles + def highlight(self, text: Text) -> None: - highlight_variables(text) + if self.variable_styles is None: + return + highlight_variables(text, self.variable_styles) class VariablesAndUrlHighlighter(Highlighter): def __init__(self, input: Input) -> None: super().__init__() self.input = input + self.variable_styles: VariableStyles = VariableStyles() + self.url_styles: UrlStyles = UrlStyles() def highlight(self, text: Text) -> None: if text.plain == "": return - highlight_url(text) - highlight_variables(text) + highlight_url(text, self.url_styles) + highlight_variables(text, self.variable_styles) + input = self.input cursor_position = input.cursor_position # type:ignore value: str = input.value diff --git a/src/posting/posting.scss b/src/posting/posting.scss index b77da5d6..3b5b9df0 100644 --- a/src/posting/posting.scss +++ b/src/posting/posting.scss @@ -141,6 +141,49 @@ Tabs { } } +UrlBar { + height: 3; + padding: 1 3 0 3; + + & #method-selector { + background: $secondary; + width: 11; + } + + & #trace-markers { + padding: 0 1; + display: none; + background: $surface; + + &.has-events { + display: block; + width: auto; + } + } + & #variable-value-bar { + width: 1fr; + color: $text-muted; + text-align: center; + height: 1; + } + & .complete-marker { + color: $success; + background: $surface; + } + & .failed-marker { + color: $error; + background: $surface; + } + & .started-marker { + color: $warning; + background: $surface; + } + & .not-started-marker { + color: $text-muted 30%; + background: $surface; + } +} + TabPane { padding: 0; diff --git a/src/posting/themes.py b/src/posting/themes.py index ed8e012e..97ddc8bc 100644 --- a/src/posting/themes.py +++ b/src/posting/themes.py @@ -1,9 +1,92 @@ +import uuid from pydantic import BaseModel, Field +from rich.style import Style from textual.design import ColorSystem +from textual.widgets.text_area import TextAreaTheme import yaml from posting.config import SETTINGS +class PostingTextAreaTheme(BaseModel): + gutter: str | None = Field(default=None) + """The style to apply to the gutter.""" + + cursor: str | None = Field(default=None) + """The style to apply to the cursor.""" + + cursor_line: str | None = Field(default=None) + """The style to apply to the line the cursor is on.""" + + cursor_line_gutter: str | None = Field(default=None) + """The style to apply to the gutter of the line the cursor is on.""" + + matched_bracket: str | None = Field(default=None) + """The style to apply to bracket matching.""" + + selection: str | None = Field(default=None) + """The style to apply to the selected text.""" + + +class SyntaxTheme(BaseModel): + """Colours used in highlighting syntax in text areas and + URL input fields.""" + + json_key: str | None = Field(default=None) + """The style to apply to JSON keys.""" + + json_string: str | None = Field(default=None) + """The style to apply to JSON strings.""" + + json_number: str | None = Field(default=None) + """The style to apply to JSON numbers.""" + + json_boolean: str | None = Field(default=None) + """The style to apply to JSON booleans.""" + + json_null: str | None = Field(default=None) + """The style to apply to JSON null values.""" + + def to_text_area_syntax_styles(self, fallback_theme: "Theme") -> dict[str, Style]: + """Convert this theme to a TextAreaTheme. + + If a fallback theme is provided, it will be used to fill in any missing + styles. + """ + syntax_styles = { + "string": Style.parse(self.json_string or fallback_theme.primary), + "number": Style.parse(self.json_number or fallback_theme.accent), + "boolean": Style.parse(self.json_boolean or fallback_theme.accent), + "null": Style.parse(self.json_null or fallback_theme.secondary), + "json.label": ( + Style.parse(self.json_key or fallback_theme.primary) + Style(bold=True) + ), + } + return syntax_styles + + +class VariableStyles(BaseModel): + """The style to apply to variables.""" + + resolved: str | None = Field(default="green") + """The style to apply to resolved variables.""" + + unresolved: str | None = Field(default="dim") + """The style to apply to unresolved variables.""" + + +class UrlStyles(BaseModel): + """The style to apply to URL input fields.""" + + base: str | None = Field(default=None) + """The style to apply to the base of the URL.""" + + protocol: str = Field(default=None) + """The style to apply to the URL protocol.""" + + separator: str = Field(default="dim b") + """The style to apply to URL separators e.g. `/`.""" + + class Theme(BaseModel): name: str = Field(exclude=True) primary: str @@ -16,9 +99,22 @@ class Theme(BaseModel): success: str | None = None accent: str | None = None dark: bool = True - syntax: str = Field(default="posting", exclude=True) + + text_area: PostingTextAreaTheme = Field(default_factory=PostingTextAreaTheme) + """Styling to apply to TextAreas.""" + + syntax: str | SyntaxTheme = Field(default="posting", exclude=True) """Posting can associate a syntax highlighting theme which will - be switched to automatically when the app theme changes.""" + be switched to automatically when the app theme changes. + + This can either be a custom SyntaxTheme or a pre-defined Textual theme + such as monokai, dracula, github_light, or vscode_dark.""" + + url: UrlStyles | None = Field(default_factory=UrlStyles) + """Styling to apply to URL input fields.""" + + variable: VariableStyles | None = Field(default_factory=VariableStyles) + """The style to apply to variables.""" # Optional metadata author: str | None = Field(default=None, exclude=True) @@ -26,11 +122,50 @@ class Theme(BaseModel): homepage: str | None = Field(default=None, exclude=True) def to_color_system(self) -> ColorSystem: - return ColorSystem(**self.model_dump()) + """Convert this theme to a ColorSystem.""" + return ColorSystem( + **self.model_dump( + exclude={ + "text_area", + "syntax", + "variable", + "url", + } + ) + ) + + def to_text_area_theme(self) -> TextAreaTheme: + """Retrieve the TextAreaTheme corresponding to this theme.""" + syntax_styles: dict[str, Style] = {} + if isinstance(self.syntax, SyntaxTheme): + syntax_styles = self.syntax.to_text_area_syntax_styles(self) + + text_area = self.text_area + return TextAreaTheme( + name=uuid.uuid4().hex, + syntax_styles=syntax_styles, + cursor_style=Style.parse(text_area.cursor) if text_area.cursor else None, + cursor_line_style=Style.parse(text_area.cursor_line) + if text_area.cursor_line + else None, + cursor_line_gutter_style=Style.parse(text_area.cursor_line_gutter) + if text_area.cursor_line_gutter + else None, + bracket_matching_style=Style.parse(text_area.matched_bracket) + if text_area.matched_bracket + else None, + selection_style=Style.parse(text_area.selection) + if text_area.selection + else None, + ) def load_user_themes() -> dict[str, Theme]: - """Load user themes from "~/.config/posting/themes".""" + """Load user themes from "~/.config/posting/themes". + + Returns: + A dictionary mapping theme names to theme objects. + """ directory = SETTINGS.get().theme_directory themes: dict[str, Theme] = {} for path in directory.iterdir(): diff --git a/src/posting/widgets/input.py b/src/posting/widgets/input.py index fa00edaf..c1de93bf 100644 --- a/src/posting/widgets/input.py +++ b/src/posting/widgets/input.py @@ -1,8 +1,98 @@ +from rich.console import Console, ConsoleOptions, RenderResult as RichRenderResult +from rich.segment import Segment +from rich.style import Style +from rich.text import Text +from textual.app import RenderResult from textual.widgets import Input +from textual._segment_tools import line_crop + from posting.config import SETTINGS +from posting.themes import Theme class PostingInput(Input): def on_mount(self) -> None: self.cursor_blink = SETTINGS.get().text_input.blinking_cursor + + self._theme_cursor_style: Style | None = None + + self.on_theme_change(self.app.themes[self.app.theme]) + self.app.theme_change_signal.subscribe(self, self.on_theme_change) + + def render(self) -> RenderResult: + self.view_position = self.view_position + if not self.value: + placeholder = Text(self.placeholder, justify="left") + placeholder.stylize(self.get_component_rich_style("input--placeholder")) + if self.has_focus: + if self._cursor_visible: + # If the placeholder is empty, there's no characters to stylise + # to make the cursor flash, so use a single space character + if len(placeholder) == 0: + placeholder = Text(" ") + placeholder.stylize(self.cursor_style, 0, 1) + return placeholder + return _InputRenderable(self, self._cursor_visible) + + @property + def cursor_style(self) -> Style: + return ( + self._theme_cursor_style + if self._theme_cursor_style is not None + else self.get_component_rich_style("input--cursor") + ) + + def on_theme_change(self, theme: Theme) -> None: + text_area_theme = theme.text_area + self._theme_cursor_style = ( + Style.parse(text_area_theme.cursor) if text_area_theme.cursor else None + ) + self.refresh() + + +class _InputRenderable: + """Render the input content.""" + + def __init__(self, input: PostingInput, cursor_visible: bool) -> None: + self.input = input + self.cursor_visible = cursor_visible + + def __rich_console__( + self, console: "Console", options: "ConsoleOptions" + ) -> RichRenderResult: + input = self.input + result = input._value + width = input.content_size.width + + # Add the completion with a faded style. + value = input.value + value_length = len(value) + suggestion = input._suggestion + show_suggestion = len(suggestion) > value_length and input.has_focus + if show_suggestion: + result += Text( + suggestion[value_length:], + input.get_component_rich_style("input--suggestion"), + ) + + if self.cursor_visible and input.has_focus: + if not show_suggestion and input._cursor_at_end: + result.pad_right(1) + cursor_position = input.cursor_position + cursor_style = input.cursor_style + result.stylize(cursor_style, cursor_position, cursor_position + 1) + + segments = list(result.render(console)) + line_length = Segment.get_line_length(segments) + if line_length < width: + segments = Segment.adjust_line_length(segments, width) + line_length = width + + line = line_crop( + list(segments), + input.view_position, + input.view_position + width, + line_length, + ) + yield from line diff --git a/src/posting/widgets/request/method_selection.py b/src/posting/widgets/request/method_selection.py index 657966f0..09fc8d23 100644 --- a/src/posting/widgets/request/method_selection.py +++ b/src/posting/widgets/request/method_selection.py @@ -22,13 +22,6 @@ class MethodSelector(PostingSelect[str]): """, ) - DEFAULT_CSS = """ -MethodSelector { - background: $secondary; - width: 11; -} -""" - BINDINGS = [ Binding("g", "select_method('GET')", "GET", show=False), Binding("p", "select_method('POST')", "POST", show=False), diff --git a/src/posting/widgets/request/url_bar.py b/src/posting/widgets/request/url_bar.py index 7fddb82e..4b426119 100644 --- a/src/posting/widgets/request/url_bar.py +++ b/src/posting/widgets/request/url_bar.py @@ -16,6 +16,7 @@ from posting.help_screen import HelpData from posting.highlighters import VariablesAndUrlHighlighter +from posting.themes import Theme from posting.variables import ( extract_variable_name, get_variable_at_cursor, @@ -83,6 +84,8 @@ def control(self) -> "UrlInput": def on_mount(self): self.highlighter = VariablesAndUrlHighlighter(self) + self.on_theme_change(self.app.themes[self.app.theme]) + self.app.theme_change_signal.subscribe(self, self.on_theme_change) @on(Input.Changed) def on_change(self, event: Input.Changed) -> None: @@ -94,6 +97,13 @@ def on_blur(self, _: Blur) -> None: def watch_cursor_position(self, cursor_position: int) -> None: self.post_message(self.CursorMoved(cursor_position, self.value, self)) + def on_theme_change(self, theme: Theme) -> None: + super().on_theme_change(theme) + if theme.variable: + self.highlighter.variable_styles = theme.variable + if theme.url: + self.highlighter.url_styles = theme.url + class SendRequestButton(Button, can_focus=False): """ @@ -124,46 +134,6 @@ class UrlBar(Vertical): The URL bar. """ - DEFAULT_CSS = """\ - UrlBar { - height: 3; - padding: 1 3 0 3; - - & #trace-markers { - padding: 0 1; - display: none; - background: $surface; - - &.has-events { - display: block; - width: auto; - } - } - & #variable-value-bar { - width: 1fr; - color: $text-muted; - text-align: center; - height: 1; - } - & .complete-marker { - color: $success; - background: $surface; - } - & .failed-marker { - color: $error; - background: $surface; - } - & .started-marker { - color: $warning; - background: $surface; - } - & .not-started-marker { - color: $text-muted 30%; - background: $surface; - } - } - """ - COMPONENT_CLASSES = { "started-marker", "complete-marker", @@ -202,6 +172,8 @@ def on_mount(self) -> None: variable_candidates=self._get_variable_candidates, ) self.screen.mount(self.auto_complete) + + self.on_theme_change(self.app.themes[self.app.theme]) self.app.theme_change_signal.subscribe(self, self.on_theme_change) @on(Input.Changed) @@ -246,6 +218,8 @@ def _get_variable_candidates(self, target_state: TargetState) -> list[DropdownIt def on_theme_change(self, theme: ColorSystem) -> None: markers = self._build_markers() self.trace_markers.update(markers) + self.url_input.notify_style_update() + self.url_input.refresh() def log_event(self, event: Event, info: dict[str, Any]) -> None: """Log an event to the request trace.""" diff --git a/src/posting/widgets/text_area.py b/src/posting/widgets/text_area.py index e2ca4360..cb94b401 100644 --- a/src/posting/widgets/text_area.py +++ b/src/posting/widgets/text_area.py @@ -13,7 +13,7 @@ from textual.widgets import TextArea, Label, Select, Checkbox from textual.widgets.text_area import Selection, TextAreaTheme from posting.config import SETTINGS -from posting.themes import Theme +from posting.themes import SyntaxTheme, Theme from posting.widgets.select import PostingSelect @@ -209,19 +209,29 @@ def on_mount(self) -> None: self.indent_width = 2 self.cursor_blink = SETTINGS.get().text_input.blinking_cursor - # Replace the default themes with CSS-aware versions. These themes will - # use their parent containers background color etc. self.register_theme(POSTING_THEME) self.register_theme(MONOKAI_THEME) self.register_theme(GITHUB_LIGHT_THEME) self.register_theme(DRACULA_THEME) + empty = len(self.text) == 0 self.set_class(empty, "empty") + self.on_theme_change(self.app.themes[self.app.theme]) self.app.theme_change_signal.subscribe(self, self.on_theme_change) def on_theme_change(self, theme: Theme) -> None: - self.theme = theme.syntax + syntax_theme = theme.syntax + if isinstance(syntax_theme, str): + # A builtin theme was requested + self.theme = syntax_theme + else: # isinstance(syntax_theme, SyntaxTheme) + # A custom theme has been specified. + # Register the theme and immediately apply it. + text_area_theme = theme.to_text_area_theme() + self.register_theme(text_area_theme) + self.theme = text_area_theme.name + self.call_after_refresh(self.refresh) @on(TextArea.Changed) diff --git a/src/posting/widgets/variable_input.py b/src/posting/widgets/variable_input.py index b639e7a4..a12a1c7a 100644 --- a/src/posting/widgets/variable_input.py +++ b/src/posting/widgets/variable_input.py @@ -1,5 +1,6 @@ from posting.help_screen import HelpData from posting.highlighters import VariableHighlighter +from posting.themes import Theme from posting.widgets.input import PostingInput from posting.widgets.variable_autocomplete import VariableAutoComplete @@ -24,3 +25,16 @@ def on_mount(self) -> None: target=self, ) self.screen.mount(self.auto_complete) + + # Trigger the callback to set the initial highlighter + + def on_theme_change(self, theme: Theme) -> None: + """Callback which fires when the app-level theme changes in order + to update the color scheme of the variable highlighter. + + Args: + theme: The new app theme. + """ + super().on_theme_change(theme) + self.highlighter.variable_styles = theme.variable + self.refresh()