Upload boredatwork.py

This commit is contained in:
John A. Hoeven 2026-08-07 11:53:20 +02:00
commit 71c88b4c42

592
boredatwork.py Normal file
View file

@ -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()