Skip to content

Commit

Permalink
Allowing themes to affect the syntax and URL highlighting
Browse files Browse the repository at this point in the history
  • Loading branch information
darrenburns committed Jul 29, 2024
1 parent fe0e9fd commit 607b4ef
Show file tree
Hide file tree
Showing 8 changed files with 334 additions and 69 deletions.
34 changes: 20 additions & 14 deletions src/posting/highlighters.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,51 +17,55 @@
_URL_REGEX = re.compile(r"(?P<protocol>https?)://(?P<base>[^/]+)(?P<path>/[^ ]*)?")


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
Expand Down
43 changes: 43 additions & 0 deletions src/posting/posting.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
143 changes: 139 additions & 4 deletions src/posting/themes.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -16,21 +99,73 @@ 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)
description: str | None = Field(default=None, exclude=True)
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():
Expand Down
90 changes: 90 additions & 0 deletions src/posting/widgets/input.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 607b4ef

Please sign in to comment.