Zalgorithm

Textual Webapp Launcher

A Textual TUI for launching web pages as web apps:

Webapp Launcher: add URL
Webapp Launcher: add URL

Webapp Launcher: launch webapps
Webapp Launcher: launch webapps

This got surprisingly complex. I’m not sure what problem it’s solving, but there are some patterns in the code that might be useful for other applications.

Python code:

from pathlib import Path
from platformdirs import user_config_dir
import tomlkit
import asyncio

from textual.app import App, ComposeResult
from textual.widgets import Footer, ListItem, ListView, Label, Input, Button, Header
from textual.containers import Horizontal, Grid
from textual import on, work
from textual.theme import Theme
from textual.screen import ModalScreen
from textual.validation import Length, URL

everforest_theme = Theme(
    name="everforest",
    primary="#a7c080",
    secondary="#83c092",
    foreground="#d3c6aa",
    background="#1e2326",
    surface="#2d353b",
    warning="#e67e80",
    error="#e67e80",
    success="#a7c080",
    accent="#d699b6",
)


def get_config_path(app_name: str, config_filename: str = "config.toml") -> Path:
    config_dir = Path(user_config_dir(app_name))
    if not config_dir.exists():
        config_dir.mkdir(parents=True, exist_ok=True)

    config_filepath = config_dir / config_filename
    if not config_filepath.exists():
        with open(config_dir / config_filename, "a"):
            pass

    return config_filepath


class AddWebappModal(ModalScreen[dict[str, str]]):
    def __init__(self):
        super().__init__()
        self.valid_title = False
        self.valid_url = False

    def compose(self) -> ComposeResult:
        yield Grid(
            Input(
                placeholder="Title",
                id="new-title",
                validators=[
                    Length(minimum=1),
                ],
            ),
            Input(placeholder="URL", id="new-url", validators=[URL()]),
            Horizontal(
                Button("Save", variant="success", disabled=True, id="save-webapp"),
                Button("Cancel", variant="warning", id="cancel-webapp"),
            ),
            id="webapp-modal",
        )

    @on(Input.Blurred, "#new-title")
    def title_blurred_handler(self, event: Input.Blurred) -> None:
        title_input = self.query_one("#new-title")
        if not event.validation_result.is_valid:
            title_input.border_title = "Title can't be empty."
            self.valid_title = False
        else:
            title_input.border_title = None
            self.valid_title = True
        self.update_button_state()

    @on(Input.Changed, "#new-title")
    def title_changed_handler(self, event: Input.Changed) -> None:
        title_input = self.query_one("#new-title")
        if not event.validation_result.is_valid:
            title_input.border_title = "Title can't be empty."
            self.valid_title = False
        else:
            title_input.border_title = None
            self.valid_title = True
        self.update_button_state()

    @on(Input.Blurred, "#new-url")
    def url_blurred_handler(self, event: Input.Blurred) -> None:
        url_input = self.query_one("#new-url")
        if not event.validation_result.is_valid:
            url_input.border_title = "Valid URL required"
            self.valid_url = False
        else:
            url_input.border_title = None
            self.valid_url = True
        self.update_button_state()

    @on(Input.Changed, "#new-url")
    def url_changed_handler(self, event: Input.Changed) -> None:
        url_input = self.query_one("#new-url")
        if not event.validation_result.is_valid:
            url_input.border_title = "Valid URL required"
            self.valid_url = False
        else:
            url_input.border_title = None
            self.valid_url = True
        self.update_button_state()

    @on(Button.Pressed, "#save-webapp")
    def save_webapp_handler(self, event: Button.Pressed) -> None:
        title = self.query_one("#new-title").value
        url = self.query_one("#new-url").value
        url_data = {"title": title, "url": url}
        self.dismiss(url_data)

    @on(Button.Pressed, "#cancel-webapp")
    def cancel_webapp_handler(self) -> None:
        self.app.pop_screen()

    def update_button_state(self) -> None:
        save_button = self.query_one("#save-webapp")
        if self.valid_title and self.valid_url:
            save_button.disabled = False
        else:
            save_button.disabled = True


class RemoveWebappModal(ModalScreen[dict[str, str]]):
    def compose(self) -> ComposeResult:
        yield Grid(
            Label(),
            Horizontal(
                Button("Cancel", variant="warning", id="cancel-remove"),
                Button("Remove", variant="success", id="confirm-remove"),
            ),
            id="remove-webapp-modal",
        )

    def on_mount(self):
        label = self.query_one(Label)
        list_view = self.app.query_one(ListView)
        config_entry = self.app.config["webapps"][list_view.index]
        label.content = f"Remove '{config_entry['title']}'?"

    @on(Button.Pressed, "#confirm-remove")
    def remove_handler(self) -> None:
        list_view = self.app.query_one(ListView)
        config = self.app.config
        del config["webapps"][list_view.index]
        with open(self.app.config_file, "w") as f:
            f.write(tomlkit.dumps(config))
        list_view.pop(list_view.index)
        self.app.pop_screen()

    @on(Button.Pressed, "#cancel-remove")
    def cancel_remove_handler(self) -> None:
        self.app.pop_screen()


class WebappLauncher(App):
    CSS_PATH = "styles.tcss"
    BINDINGS = [
        ("a", "add_webapp()", "Add web app"),
        ("r", "remove_webapp()", "Remove web app"),
    ]

    def on_mount(self):
        self.register_theme(everforest_theme)
        self.theme = "everforest"
        self.title = "Webapp Launcher"
        self.config_file = str(get_config_path("webapp_launcher"))
        with open(self.config_file, "r") as f:
            self.config = tomlkit.parse(f.read())

        if self.config.get("webapps"):
            list_view = self.query_one(ListView)
            for item in self.config["webapps"]:
                title = item["title"]
                list_view.append(ListItem(Label(title)))
            list_view.index = 0
        else:
            aot = tomlkit.aot()
            self.config.append("webapps", aot)
            with open(self.config_file, "w") as f:
                f.write(tomlkit.dumps(self.config))

    def compose(self) -> ComposeResult:
        yield Header()
        yield ListView()
        yield Footer()

    def action_add_webapp(self) -> None:

        # callback function to be run when dismiss() is called from
        # the AddUrlModal() class:
        def dismiss_modal_callback(webapp_data: dict[str, str]) -> None:
            title = webapp_data["title"]
            url = webapp_data["url"]
            webapp = tomlkit.table()
            webapp["title"] = title
            webapp["url"] = url
            self.config["webapps"].append(webapp)
            with open(self.config_file, "w") as f:
                f.write(tomlkit.dumps(self.config))
            list_view = self.query_one(ListView)
            list_view.append(ListItem(Label(title)))
            list_view.index = len(list_view.children) - 1

        self.push_screen(AddWebappModal(), dismiss_modal_callback)

    def action_remove_webapp(self) -> None:
        list_view = self.query_one(ListView)
        if list_view.index is not None:
            self.push_screen(RemoveWebappModal())

    @on(ListView.Selected)
    def handle_listview_selected(self, event: ListView.Selected) -> None:
        webapp_info: dict[str, str] = self.config["webapps"][event.index]
        url = webapp_info["url"]
        self.launch_webapp(url)

    @work()
    async def launch_webapp(self, url: str) -> None:
        self.proc = await asyncio.create_subprocess_exec("chromium", f"--app={url}")


if __name__ == "__main__":
    app = WebappLauncher()
    app.run()

Styles:

AddWebappModal {
  align: center middle;
}

ListView {
  margin: 1 1;
}

Label {
  padding: 1 2;
}

#webapp-modal {
  grid-size: 1 3;
  grid-rows: auto auto 4;
  grid-gutter: 1 2;
  width: 60;
  height: 14;
  border: thick $background 80%;
}

#webapp-modal Button {
  margin: 0 1;
}

RemoveWebappModal {
  align: center middle;
}

#remove-webapp-modal {
  grid-size: 1 2;
  grid-rows: auto 4;
  grid-gutter: 1 2;
  width: 60;
  height: 9;
  border: thick $background 80%;
}

#remove-webapp-modal Button {
  margin: 0 1;
}

Input.-valid {
  border: tall $success 60%;
}

Input.-valid:focus {
  border: tall $success;
}

Pretty {
  height: 1;
  margin: 0;
  padding: 0;
}