diff --git a/boredatwork.py b/boredatwork.py new file mode 100644 index 0000000..3b8abb4 --- /dev/null +++ b/boredatwork.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +""" +boredatwork — a pomodoro timer with a pool of harmless break-time diversions. + +Run: python3 boredatwork.py + +Keys (from the main screen): + s start / pause + r reset current session + o options (adjust focus / break length) + b surprise me (boredatwork diversion, any time) + q quit +""" + +import json +import random +from pathlib import Path + +from textual.app import App, ComposeResult +from textual.screen import Screen, ModalScreen +from textual.containers import Vertical, Horizontal, Center, Middle +from textual.widgets import Static, Button, Input, Label +from textual.reactive import reactive +from textual.binding import Binding + +CONFIG_DIR = Path.home() / ".config" / "boredatwork" +CONFIG_PATH = CONFIG_DIR / "config.json" + +DEFAULT_CONFIG = {"focus_minutes": 25, "break_minutes": 5} + + +def load_config() -> dict: + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + cfg = dict(DEFAULT_CONFIG) + cfg.update(data) + return cfg + except Exception: + return dict(DEFAULT_CONFIG) + + +def save_config(cfg: dict) -> None: + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f) + except Exception: + pass # settings persistence is a nicety, never a blocker + + +# --------------------------------------------------------------------------- +# Diversion content — all local, all static, nothing fetched or generated. +# --------------------------------------------------------------------------- + +JOKES = [ + "Why do programmers prefer dark mode? Because light attracts bugs.", + "I told my computer I needed a break. It said no problem, it'll go to sleep too.", + "Why did the Raspberry Pi apply for a loan? It wanted a bigger case.", + "A SQL query walks into a bar, walks up to two tables and asks: mind if I join you?", + "I'd tell you a UDP joke, but you might not get it.", + "There are 10 kinds of people: those who understand binary, and those who don't.", + "Why was the network administrator always calm? Great subnet management.", + "I changed my password to 'incorrect' — now when I forget it, it tells me.", + "Debugging: being the detective in a crime movie where you are also the murderer.", + "Why do Java developers wear glasses? Because they don't see sharp.", + "My code doesn't have bugs, it has undocumented features.", + "Why did the Wi-Fi break up with the router? It felt no connection.", + "An optimist and a pessimist walk into a bar. It's actually half a bar.", + "I've got a great joke about UDP, but I'm not sure you'll get it.", + "Real programmers count from zero, and occasionally lose an argument about it.", +] + +TRIVIA = [ + "The first computer mouse, built in 1964, was carved out of wood.", + "The Basilica di San Gaudenzio in Novara has one of the tallest domes in Italy.", + "A single bolt of lightning contains enough energy to toast about 100,000 slices of bread.", + "Brianza has been a recognised centre of Italian furniture-making for over a century.", + "The QWERTY keyboard layout was originally designed to slow typists down, to stop typewriter jams.", + "Honey never spoils — edible honey has been found in tombs thousands of years old.", + "The first 1GB hard drive, released in 1980, weighed about 250 kilograms.", + "Octopuses have three hearts, and two of them stop beating when they swim.", + "The word 'robot' comes from the Czech word 'robota', meaning forced labour.", + "Milan's Duomo took nearly six centuries to complete, from 1386 to 1965.", + "A group of crows is called a murder; a group of ravens is called an unkindness.", + "The first email was sent in 1971, by an engineer emailing the desk next to him.", + "Bamboo can grow up to 91 cm in a single day under the right conditions.", + "The shortest war in recorded history lasted about 38 minutes, in 1896.", + "There are more possible chess games than atoms in the observable universe.", +] + +RIDDLES = [ + ("What has keys but no locks, space but no room, and you can enter but not go in?", + "A keyboard."), + ("The more you take, the more you leave behind. What am I?", + "Footsteps."), + ("What has a heart that doesn't beat?", + "An artichoke."), + ("I'm light as a feather, yet the strongest person can't hold me for long. What am I?", + "Your breath."), + ("What gets wetter the more it dries?", + "A towel."), + ("What has a neck but no head?", + "A bottle."), + ("The person who makes it, sells it. The person who buys it never uses it. What is it?", + "A coffin."), + ("What can travel around the world while staying in a corner?", + "A stamp."), + ("I have branches, but no fruit, trunk, or leaves. What am I?", + "A bank."), + ("What five-letter word becomes shorter when you add two letters to it?", + "Short."), +] + +COMPLIMENTS = [ + "You've read this far into a break-timer's silly text bank. Genuine dedication.", + "Your solder joints are probably cleaner than most people's handwriting.", + "Somewhere, a client's ancient PC is grateful you exist.", + "You debug things at 11pm so other people don't have to think about it at all.", + "Your documentation habit alone puts you ahead of most of the industry.", + "You've earned this break. The Ryzen isn't going anywhere.", + "Whoever built your workbench setup clearly knows what they're doing. Oh, that was you.", + "You've kept a system running for longer than some startups have existed.", + "The next person who inherits your notes is going to be pleasantly surprised.", + "Not everyone air-gaps a Raspberry Pi for fun. Respect.", +] + +MADLIBS = [ + { + "blanks": ["a noun", "a verb ending in -ing", "an adjective", "a place", + "a plural noun", "an adverb"], + "template": ( + "The client called about their {0}. Apparently it had been {1} all " + "morning, which the client described as '{2}'. I drove out to {3}, " + "opened the case, and found three {4} inside — clearly the real " + "problem. I fixed it {5} and left before they could ask how." + ), + }, + { + "blanks": ["a number", "an animal", "a noun", "an adjective", + "a body part", "a verb (past tense)"], + "template": ( + "The server room had been running fine for {0} days when a {1} " + "got into the ventilation and knocked loose the {2}. The whole " + "rack went {3}, the fans started sounding like a distressed {4}, " + "and the whole thing only {5} after I unplugged it and counted to ten." + ), + }, + { + "blanks": ["a colour", "a kitchen appliance", "a verb ending in -ing", + "a Brianza town", "an adjective", "a small object"], + "template": ( + "The new client's office was entirely {0}, including — " + "inexplicably — the {1}. I spent the afternoon {2} cables while " + "someone from {3} called three times about a {4} invoice. " + "In the end the whole fix came down to a single {5} taped to the router." + ), + }, + { + "blanks": ["a liquid", "a piece of furniture", "an emotion", + "a type of weather", "a plural noun", "a verb ending in -ing"], + "template": ( + "Someone spilled {0} directly onto the {1} during the demo. " + "The reaction in the room was pure {2} — outside it had started " + "doing that thing where the {3} makes all the {4} act strange. " + "I kept {5} and pretended this was a completely normal Tuesday." + ), + }, + { + "blanks": ["a number", "a tool", "an adjective", "a sound effect", + "a plural noun", "a place"], + "template": ( + "It took exactly {0} attempts with a {1} before the case finally " + "came apart. The screws were {2}, the fan made a sound like " + "'{3}', and inside were nothing but loose {4} and a receipt " + "from {5}. Some builds keep their secrets better than others." + ), + }, +] + + +def pick_diversion(exclude_kind: str | None = None) -> tuple[str, object]: + kinds = ["joke", "trivia", "riddle", "compliment", "madlib"] + if exclude_kind and len(kinds) > 1: + kinds = [k for k in kinds if k != exclude_kind] + kind = random.choice(kinds) + if kind == "joke": + return kind, random.choice(JOKES) + if kind == "trivia": + return kind, random.choice(TRIVIA) + if kind == "riddle": + return kind, random.choice(RIDDLES) + if kind == "compliment": + return kind, random.choice(COMPLIMENTS) + return kind, random.choice(MADLIBS) + + +# --------------------------------------------------------------------------- +# Screens +# --------------------------------------------------------------------------- + +class SettingsScreen(ModalScreen[None]): + BINDINGS = [Binding("escape", "dismiss_screen", "Close")] + + def __init__(self, cfg: dict) -> None: + super().__init__() + self.cfg = cfg + + def compose(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="settings-box"): + yield Label("Options", id="settings-title") + yield Label("Focus length (minutes)") + yield Input(value=str(self.cfg["focus_minutes"]), id="focus-input") + yield Label("Break length (minutes)") + yield Input(value=str(self.cfg["break_minutes"]), id="break-input") + with Horizontal(id="settings-buttons"): + yield Button("Save", id="save-btn", variant="success") + yield Button("Cancel", id="cancel-btn") + + def action_dismiss_screen(self) -> None: + self.dismiss(None) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "cancel-btn": + self.dismiss(None) + return + if event.button.id == "save-btn": + focus_in = self.query_one("#focus-input", Input) + break_in = self.query_one("#break-input", Input) + try: + focus_val = max(1, int(focus_in.value)) + except ValueError: + focus_val = self.cfg["focus_minutes"] + try: + break_val = max(1, int(break_in.value)) + except ValueError: + break_val = self.cfg["break_minutes"] + self.cfg["focus_minutes"] = focus_val + self.cfg["break_minutes"] = break_val + save_config(self.cfg) + self.dismiss(None) + + +class MadlibScreen(ModalScreen[None]): + """Collects blanks one at a time, then reveals the finished story.""" + + BINDINGS = [Binding("escape", "dismiss_screen", "Close")] + + def __init__(self, madlib: dict) -> None: + super().__init__() + self.madlib = madlib + self.answers: list[str] = [] + self.index = 0 + + def compose(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="madlib-box"): + yield Label("Madlib", id="madlib-title") + yield Static(id="madlib-prompt") + yield Input(placeholder="type here", id="madlib-input") + with Horizontal(id="madlib-buttons"): + yield Button("Next", id="next-btn", variant="success") + yield Button("Close", id="close-btn") + + def on_mount(self) -> None: + self.run_worker(self._show_prompt(), exclusive=True) + + async def _show_prompt(self) -> None: + blanks = self.madlib["blanks"] + if self.index < len(blanks): + prompt = self.query_one("#madlib-prompt", Static) + prompt.update(f"Give me {blanks[self.index]}:") + box = self.query_one("#madlib-input", Input) + box.value = "" + box.focus() + else: + await self._reveal() + + async def _reveal(self) -> None: + story = self.madlib["template"].format(*self.answers) + box = self.query_one("#madlib-box", Vertical) + await box.remove_children() + await box.mount(Label("Your story", id="madlib-title")) + await box.mount(Static(story, id="madlib-story")) + await box.mount(Horizontal(Button("Close", id="close-btn"), id="madlib-buttons")) + + async def _submit(self) -> None: + box = self.query_one("#madlib-input", Input) + value = box.value.strip() or "(blank)" + self.answers.append(value) + self.index += 1 + await self._show_prompt() + + async def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "madlib-input": + await self._submit() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "close-btn": + self.dismiss(None) + elif event.button.id == "next-btn": + await self._submit() + + def action_dismiss_screen(self) -> None: + self.dismiss(None) + + +class DiversionScreen(ModalScreen[None]): + BINDINGS = [Binding("escape", "dismiss_screen", "Close")] + + KIND_LABELS = { + "joke": "Joke", + "trivia": "Trivia", + "riddle": "Riddle", + "compliment": "Just because", + "madlib": "Madlib", + } + + def __init__(self) -> None: + super().__init__() + self.kind, self.content = pick_diversion() + self.revealed = False + + def compose(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="diversion-box"): + yield Label(self.KIND_LABELS[self.kind], id="diversion-tag") + yield Static(id="diversion-body") + with Horizontal(id="diversion-buttons"): + yield Button("Another", id="another-btn") + if self.kind == "riddle": + yield Button("Reveal answer", id="reveal-btn") + if self.kind == "madlib": + yield Button("Play this one", id="play-madlib-btn", + variant="success") + yield Button("Back to work", id="close-btn", variant="success") + + def on_mount(self) -> None: + self._render_body() + + def _render_body(self) -> None: + body = self.query_one("#diversion-body", Static) + if self.kind == "riddle": + question, _ = self.content + body.update(question) + elif self.kind == "madlib": + body.update( + "A fill-in-the-blank story is ready. Press 'Play this one' " + "to supply the words." + ) + else: + body.update(self.content) + + async def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "close-btn": + self.dismiss(None) + elif event.button.id == "another-btn": + self.kind, self.content = pick_diversion(exclude_kind=self.kind) + self.query_one("#diversion-tag", Label).update( + self.KIND_LABELS[self.kind] + ) + await self._rebuild_buttons() + self._render_body() + elif event.button.id == "reveal-btn": + _, answer = self.content + self.query_one("#diversion-body", Static).update(answer) + elif event.button.id == "play-madlib-btn": + self.app.push_screen(MadlibScreen(self.content)) + + async def _rebuild_buttons(self) -> None: + row = self.query_one("#diversion-buttons", Horizontal) + await row.remove_children() + await row.mount(Button("Another", id="another-btn")) + if self.kind == "riddle": + await row.mount(Button("Reveal answer", id="reveal-btn")) + if self.kind == "madlib": + await row.mount( + Button("Play this one", id="play-madlib-btn", variant="success") + ) + await row.mount(Button("Back to work", id="close-btn", variant="success")) + + def action_dismiss_screen(self) -> None: + self.dismiss(None) + + +class MainScreen(Screen): + BINDINGS = [ + Binding("s", "toggle_run", "Start/pause"), + Binding("r", "reset_session", "Reset"), + Binding("o", "open_settings", "Options"), + Binding("b", "open_diversion", "Surprise me"), + Binding("q", "quit", "Quit"), + ] + + remaining = reactive(0) + running = reactive(False) + mode = reactive("focus") # "focus" or "break" + + def __init__(self) -> None: + super().__init__() + self.cfg = load_config() + self.remaining = self.cfg["focus_minutes"] * 60 + self._timer = None + + def compose(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="console"): + yield Label("CERVELLO ELETTRICO", id="brand-label") + yield Label("boredatwork", id="app-label") + yield Static("FOCUS", id="mode-label") + yield Static("25:00", id="clock") + with Horizontal(id="controls"): + yield Button("Start", id="start-btn", variant="success") + yield Button("Reset", id="reset-btn") + yield Button("Options", id="options-btn") + with Horizontal(id="controls2"): + yield Button("Surprise me", id="surprise-btn", variant="primary") + yield Static( + "s start/pause r reset o options b surprise me q quit", + id="hint-bar", + ) + + def on_mount(self) -> None: + self._update_clock() + + def _update_clock(self) -> None: + mins, secs = divmod(max(0, self.remaining), 60) + self.query_one("#clock", Static).update(f"{mins:02d}:{secs:02d}") + self.query_one("#mode-label", Static).update( + "FOCUS" if self.mode == "focus" else "BREAK" + ) + console = self.query_one("#console", Vertical) + console.set_class(self.mode == "break", "break-mode") + + def action_toggle_run(self) -> None: + self.running = not self.running + start_btn = self.query_one("#start-btn", Button) + if self.running: + start_btn.label = "Pause" + self._timer = self.set_interval(1, self._tick) + else: + start_btn.label = "Start" + if self._timer: + self._timer.stop() + self._timer = None + + def action_reset_session(self) -> None: + if self._timer: + self._timer.stop() + self._timer = None + self.running = False + self.query_one("#start-btn", Button).label = "Start" + minutes = ( + self.cfg["focus_minutes"] if self.mode == "focus" + else self.cfg["break_minutes"] + ) + self.remaining = minutes * 60 + self._update_clock() + + def action_open_settings(self) -> None: + self.app.push_screen(SettingsScreen(self.cfg)) + + def action_open_diversion(self) -> None: + self.app.push_screen(DiversionScreen()) + + def _tick(self) -> None: + self.remaining -= 1 + if self.remaining <= 0: + self._switch_mode() + self._update_clock() + + def _switch_mode(self) -> None: + self.mode = "break" if self.mode == "focus" else "focus" + minutes = ( + self.cfg["focus_minutes"] if self.mode == "focus" + else self.cfg["break_minutes"] + ) + self.remaining = minutes * 60 + if self.mode == "break": + self.app.push_screen(DiversionScreen()) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "start-btn": + self.action_toggle_run() + elif event.button.id == "reset-btn": + self.action_reset_session() + elif event.button.id == "options-btn": + self.action_open_settings() + elif event.button.id == "surprise-btn": + self.action_open_diversion() + + +class BoredAtWorkApp(App): + TITLE = "boredatwork" + CSS = """ + Screen { + background: #1b1917; + } + + #console { + width: 56; + border: round #6b6560; + padding: 1 3; + background: #201d1a; + } + + #console.break-mode { + border: round #7fbf4d; + } + + #brand-label { + color: #6b6560; + text-style: bold; + content-align: center middle; + width: 100%; + } + + #app-label { + color: #c1793d; + content-align: center middle; + width: 100%; + margin-bottom: 1; + } + + #mode-label { + content-align: center middle; + width: 100%; + color: #c1793d; + text-style: bold; + } + + #clock { + content-align: center middle; + width: 100%; + text-style: bold; + color: #efe7da; + margin: 1 0; + } + + #controls, #controls2 { + align: center middle; + margin-bottom: 1; + } + + #hint-bar { + color: #6b6560; + content-align: center middle; + width: 100%; + margin-top: 1; + } + + #settings-box, #diversion-box, #madlib-box { + width: 60; + border: round #c1793d; + padding: 1 3; + background: #201d1a; + } + + #settings-title, #diversion-tag, #madlib-title { + text-style: bold; + color: #c1793d; + content-align: center middle; + width: 100%; + margin-bottom: 1; + } + + #diversion-body, #madlib-story, #madlib-prompt { + color: #efe7da; + margin-bottom: 1; + } + + #settings-buttons, #diversion-buttons, #madlib-buttons { + align: center middle; + margin-top: 1; + } + """ + + def on_mount(self) -> None: + self.push_screen(MainScreen()) + + +def main() -> None: + BoredAtWorkApp().run() + + +if __name__ == "__main__": + main() \ No newline at end of file