Skip to content

Commit

Permalink
Merge pull request #151 from darrenburns/fixes-18nov24
Browse files Browse the repository at this point in the history
Live theme editing, upgrading Textual
  • Loading branch information
darrenburns authored Nov 19, 2024
2 parents 7b27cb3 + e5e9e6b commit c3fda21
Show file tree
Hide file tree
Showing 52 changed files with 3,803 additions and 3,810 deletions.
Binary file modified .coverage
Binary file not shown.
6 changes: 6 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Unreleased

### Fixed

- Fixed crash when invalid syntax theme is specified. Posting now exits cleanly with an error message.

## 2.2.0 [17th November 2024]

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ The table below lists all available configuration options and their environment
| `layout` (`POSTING_LAYOUT`) | `"vertical"`, `"horizontal"` (Default: `"horizontal"`) | Sets the layout of the application. |
| `use_host_environment` (`POSTING_USE_HOST_ENVIRONMENT`) | `true`, `false` (Default: `false`) | Allow/deny using environment variables from the host machine in requests via `$env:` syntax. When disabled, only variables defined explicitly in `.env` files will be available for use. |
| `watch_env_files` (`POSTING_WATCH_ENV_FILES`) | `true`, `false` (Default: `true`) | If enabled, automatically reload environment files when they change. |
| `watch_themes` (`POSTING_WATCH_THEMES`) | `true`, `false` (Default: `true`) | If enabled, automatically reload themes in the theme directory when they change on disk. |
| `watch_collection_files` (`POSTING_WATCH_COLLECTION_FILES`) | `true`, `false` (Default: `true`) | If enabled, automatically reload collection files when they change on disk. Right now, this is limited to reloading Python scripts in the collection. |
| `animation` (`POSTING_ANIMATION`) | `"none"`, `"basic"`, `"full"` (Default: `"none"`) | Controls the animation level. |
| `response.prettify_json` (`POSTING_RESPONSE__PRETTIFY_JSON`) | `true`, `false` (Default: `true`) | If enabled, JSON responses will be pretty-formatted. |
| `response.show_size_and_time` (`POSTING_RESPONSE__SHOW_SIZE_AND_TIME`) | `true`, `false` (Default: `true`) | If enabled, the size and time taken for the response will be displayed in the response area border subtitle. |
Expand Down
9 changes: 8 additions & 1 deletion docs/guide/themes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
## Overview

Posting ships with several built-in themes, and also supports custom, user-made themes.
With themes, you can customise most aspects of the color palette used in the application, as well as the syntax highlighting.

When editing a theme on disk, Posting can show a live preview of the theme in effect, making it easy to design and test themes.

### Creating a theme

Expand Down Expand Up @@ -36,6 +37,12 @@ theme: example

Note that the theme name is *not* defined by the filename, but by the `name` field in the theme file.

!!! tip

If you edit a theme on disk while Posting is using it, the UI will automatically
refresh to reflect the changes you've made. This is enabled by default, but if you'd
like to disable it, you can set `watch_themes` to `false` in your `config.yaml`.

#### Syntax highlighting

Syntax highlighted elements such as the URL bar, text areas, and fields which contain variables will be colored based on the semantic colors defined in the theme (`primary`, `secondary`, etc) by default.
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ dependencies = [
"pyyaml>=6.0.2,<7.0.0",
"pydantic-settings>=2.4.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0",
"textual[syntax]==0.86.1",
"textual[syntax]==0.86.2",
# pinned intentionally
"textual-autocomplete==3.0.0a12",
"textual-autocomplete==3.0.0a13",
# pinned intentionally
"watchfiles>=0.24.0",
]
Expand Down
50 changes: 45 additions & 5 deletions src/posting/app.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import inspect
from contextlib import redirect_stdout, redirect_stderr
from itertools import cycle
from pathlib import Path
from typing import Any, Literal, cast

import httpx
from rich.console import RenderableType
from textual.content import Content

from posting.importing.curl import CurlImport
from textual import on, log, work
from textual import messages, on, log, work
from textual.command import CommandPalette
from textual.css.query import NoMatches
from textual.events import Click
from textual.reactive import Reactive, reactive
from textual.app import App, ComposeResult
from textual.app import App, ComposeResult, ReturnType
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
Expand Down Expand Up @@ -44,7 +44,7 @@
from posting.jump_overlay import JumpOverlay
from posting.jumper import Jumper
from posting.scripts import execute_script, uncache_module, Posting as PostingContext
from posting.themes import BUILTIN_THEMES, load_user_themes
from posting.themes import BUILTIN_THEMES, load_user_theme, load_user_themes
from posting.types import CertTypes, PostingLayout
from posting.user_host import get_user_host_string
from posting.variables import SubstitutionError, get_variables, update_variables
Expand Down Expand Up @@ -681,7 +681,7 @@ def on_curl_message(self, event: CurlMessage):
try:
curl_import = CurlImport(event.curl_command)
request_model = curl_import.to_request_model()
except Exception as e:
except Exception:
self.notify(
title="Import error",
message="Couldn't import curl command.",
Expand Down Expand Up @@ -932,6 +932,22 @@ async def watch_collection_files(self) -> None:
# of the available scripts.
pass

@work(exclusive=True, group="theme-watcher")
async def watch_themes(self) -> None:
"""Watching the theme directory for changes."""
async for changes in awatch(self.settings.theme_directory):
print("Theme changes detected")
for change_type, file_path in changes:
if file_path.endswith((".yml", ".yaml")):
theme = load_user_theme(Path(file_path))
if theme and theme.name == self.theme:
self.register_theme(theme)
self.set_reactive(App.theme, theme.name)
try:
self._watch_theme(theme.name)
except Exception as e:
print(f"Error refreshing CSS: {e}")

def on_mount(self) -> None:
settings = SETTINGS.get()

Expand Down Expand Up @@ -987,6 +1003,9 @@ def on_mount(self) -> None:
if self.settings.watch_collection_files:
self.watch_collection_files()

if self.settings.watch_themes:
self.watch_themes()

def get_default_screen(self) -> MainScreen:
self.main_screen = MainScreen(
collection=self.collection,
Expand Down Expand Up @@ -1087,3 +1106,24 @@ def reset_focus(_) -> None:

self.set_focus(None)
await self.push_screen(HelpScreen(widget=focused), callback=reset_focus)

def exit(
self,
result: ReturnType | None = None,
return_code: int = 0,
message: RenderableType | None = None,
) -> None:
"""Exit the app, and return the supplied result.
Args:
result: Return value.
return_code: The return code. Use non-zero values for error codes.
message: Optional message to display on exit.
"""
self._exit = True
self._return_value = result
self._return_code = return_code
self.post_message(messages.ExitApp())
if message:
self._exit_renderables.append(message)
self._exit_renderables = list(set(self._exit_renderables))
3 changes: 3 additions & 0 deletions src/posting/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ class Settings(BaseSettings):
watch_collection_files: bool = Field(default=True)
"""If enabled, automatically reload collection files when they change."""

watch_themes: bool = Field(default=True)
"""If enabled, automatically reload themes in the theme directory when they change on disk."""

text_input: TextInputSettings = Field(default_factory=TextInputSettings)
"""General configuration for inputs and text area widgets."""

Expand Down
1 change: 1 addition & 0 deletions src/posting/exit_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
GENERAL_ERROR = 1
6 changes: 2 additions & 4 deletions src/posting/jump_overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Center
from textual.geometry import Offset
from textual.screen import ModalScreen
from textual.widget import Widget
from textual.widgets import Label

from posting.jumper import JumpInfo

if TYPE_CHECKING:
from posting.jumper import Jumper

Expand Down Expand Up @@ -77,7 +74,8 @@ def compose(self) -> ComposeResult:
for offset, jump_info in self.overlays.items():
key, _widget = jump_info
label = Label(key, classes="textual-jump-label")
label.styles.offset = offset
x, y = offset
label.styles.margin = y, x
yield label
with Center(id="textual-jump-info"):
yield Label("Press a key to jump")
Expand Down
3 changes: 2 additions & 1 deletion src/posting/posting.scss
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,14 @@ Select {
}

.textual-jump-label {
layer: textual-jump;
dock: top;
color: $text-accent;
background: $accent-muted;
text-style: bold;
padding: 0 1;
margin-right: 1;
height: 1;
width: auto;
}

#textual-jump-info {
Expand Down
58 changes: 12 additions & 46 deletions src/posting/themes.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from pathlib import Path
import uuid
from pydantic import BaseModel, Field
from rich.style import Style
Expand Down Expand Up @@ -48,23 +49,6 @@ class SyntaxTheme(BaseModel):
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),
"json.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."""
Expand Down Expand Up @@ -170,33 +154,6 @@ def to_color_system(self) -> ColorSystem:
)
)

def to_text_area_theme(self) -> TextAreaTheme:
"""Retrieve the TextAreaTheme corresponding to this theme."""
if isinstance(self.syntax, SyntaxTheme):
syntax = self.syntax.to_text_area_syntax_styles(self)
else:
syntax = TextAreaTheme.get_builtin_theme(self.syntax)

text_area = self.text_area
return TextAreaTheme(
name=uuid.uuid4().hex,
syntax_styles=syntax,
gutter_style=Style.parse(text_area.gutter) if text_area.gutter else None,
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 to_textual_theme(self) -> TextualTheme:
"""Convert this theme to a Textual Theme.
Expand Down Expand Up @@ -372,7 +329,16 @@ def load_user_themes() -> dict[str, TextualTheme]:
return themes


galaxy_primary = Color.parse("#8A2BE2")
def load_user_theme(path: Path) -> TextualTheme | None:
with path.open() as theme_file:
theme_content = yaml.load(theme_file, Loader=yaml.FullLoader) or {}
try:
return Theme(**theme_content).to_textual_theme()
except KeyError:
raise ValueError(f"Invalid theme file {path}. A `name` is required.")


galaxy_primary = Color.parse("#C45AFF")
galaxy_secondary = Color.parse("#a684e8")
galaxy_warning = Color.parse("#FFD700")
galaxy_error = Color.parse("#FF4500")
Expand All @@ -397,7 +363,7 @@ def load_user_themes() -> dict[str, TextualTheme]:
panel=galaxy_panel.hex,
dark=True,
variables={
"input-cursor-background": galaxy_primary.hex,
"input-cursor-background": "#C45AFF",
"footer-background": "transparent",
},
),
Expand Down
11 changes: 9 additions & 2 deletions src/posting/widgets/text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
from textual.reactive import Reactive, reactive
from textual.theme import Theme as TextualTheme
from textual.widgets import Checkbox, Label, Select, TextArea
from textual.widgets.text_area import Selection, TextAreaTheme
from textual.widgets.text_area import Selection, TextAreaTheme, ThemeDoesNotExist
from typing_extensions import Literal

from posting.config import SETTINGS
from posting.exit_codes import GENERAL_ERROR
from posting.themes import Theme
from posting.widgets.select import PostingSelect

Expand Down Expand Up @@ -152,7 +153,13 @@ def on_theme_change(self, theme: TextualTheme) -> None:
builtin_theme = theme.variables.get("syntax-theme")
if isinstance(builtin_theme, str):
# A builtin theme was requested
self.theme = builtin_theme
try:
self.theme = builtin_theme
except ThemeDoesNotExist:
self.app.exit(
return_code=GENERAL_ERROR,
message=f"The syntax theme {builtin_theme!r} is invalid.",
)
else:
# Generate a TextAreaTheme from the Textual them
text_area_theme = Theme.text_area_theme_from_theme_variables(
Expand Down
4 changes: 2 additions & 2 deletions src/posting/xresources.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ def load_xresources_themes() -> dict[str, TextualTheme]:
name="xresources-dark",
**supplied_colors,
dark=True,
),
).to_textual_theme(),
"xresources-light": Theme(
name="xresources-light",
**supplied_colors,
dark=False,
),
).to_textual_theme(),
}
Loading

0 comments on commit c3fda21

Please sign in to comment.