decafe-timer 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ __version__ = "0.4.3"
@@ -0,0 +1,4 @@
1
+ # SPDX-FileCopyrightText: 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ from .main import main
decafe_timer/main.py ADDED
@@ -0,0 +1,672 @@
1
+ import hashlib
2
+ import json
3
+ import random
4
+ import re
5
+ import time
6
+ from datetime import datetime, timedelta
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from appdirs import user_cache_dir
11
+ from rich.console import Console
12
+ from rich.progress import (
13
+ BarColumn,
14
+ Progress,
15
+ TextColumn,
16
+ )
17
+
18
+ from .__about__ import __version__
19
+
20
+ APP_NAME = "coffee_timer"
21
+ APP_AUTHOR = "tos-kamiya"
22
+
23
+ CACHE_DIR = Path(user_cache_dir(APP_NAME, APP_AUTHOR))
24
+ STATE_FILE = CACHE_DIR / "timer_state.json"
25
+
26
+ COLORED_CONSOLE = Console(markup=False, highlight=False)
27
+ PLAIN_CONSOLE = Console(color_system=None, markup=False, highlight=False)
28
+
29
+ EXPIRED_MESSAGES = [
30
+ "Cooldown expired! ☕ You may drink coffee now.",
31
+ # やさしく励ましてくれる系
32
+ "Your break is over -- enjoy your coffee, gently.",
33
+ "You’ve waited well. Treat yourself to a warm cup.",
34
+ # ふんわり癒し系
35
+ "Your coffee time has arrived -- relax and enjoy.",
36
+ "A warm cup is waiting for you.",
37
+ "The timer’s done. Brew a moment of comfort.",
38
+ # ちょっとユーモア系
39
+ "Permission granted: caffeination may proceed.",
40
+ "Coffee mode unlocked. Use wisely.",
41
+ # 行動変容をそっと支援する系
42
+ "If you choose to, a small cup won’t hurt now.",
43
+ "Ready when you are. Keep listening to your body.",
44
+ ]
45
+ NO_ACTIVE_TIMER_MESSAGE = "No active timer."
46
+
47
+
48
+ def _get_console(*, one_line: bool = False, graph_only: bool = False) -> Console:
49
+ return PLAIN_CONSOLE if (one_line or graph_only) else COLORED_CONSOLE
50
+
51
+
52
+ ONE_LINE_BAR_WIDTH = int(max(len(m) for m in EXPIRED_MESSAGES) * 0.7 + 1)
53
+ DURATION_PATTERN = re.compile(r"(\d+)([hms])", re.IGNORECASE)
54
+ FRACTION_SPLIT_PATTERN = re.compile(r"\s*/\s*")
55
+ BAR_FILLED_CHAR = "\U0001d15b" # black vertical rectangle
56
+ BAR_EMPTY_CHAR = "\U0001d15a" # white vertical rectangle
57
+
58
+ INVALID_DURATION_MESSAGE = (
59
+ "Invalid duration. Use AhBmCs (e.g. 2h30m) or HH:MM:SS. "
60
+ "You can also use remaining/total like 3h/5h."
61
+ )
62
+
63
+
64
+ def _select_expired_message(
65
+ finish_at: Optional[datetime],
66
+ duration_sec: Optional[int],
67
+ ) -> str:
68
+ if finish_at is None or duration_sec is None:
69
+ return random.choice(EXPIRED_MESSAGES)
70
+ key = f"{finish_at.isoformat()}-{duration_sec}".encode("utf-8")
71
+ digest = hashlib.sha256(key).digest()
72
+ index = int.from_bytes(digest[:8], "big") % len(EXPIRED_MESSAGES)
73
+ return EXPIRED_MESSAGES[index]
74
+
75
+
76
+ def _schedule_timer_seconds(remaining_sec: int, total_sec: int):
77
+ """Create a new timer from seconds, persist it, and return (finish_at, duration_sec)."""
78
+ if remaining_sec <= 0 or total_sec <= 0:
79
+ raise ValueError("Duration must be positive.")
80
+ finish_at = datetime.now() + timedelta(seconds=remaining_sec)
81
+ save_state(finish_at, total_sec)
82
+ return finish_at, total_sec
83
+
84
+
85
+ def _schedule_timer(hours: int, minutes: int, seconds: int):
86
+ """Create a new timer, persist it, and return (finish_at, duration_sec)."""
87
+ duration_sec = _duration_to_seconds(hours, minutes, seconds)
88
+ return _schedule_timer_seconds(duration_sec, duration_sec)
89
+
90
+
91
+ # ------------------------------
92
+ # 永続化まわり
93
+ # ------------------------------
94
+ def _read_state_payload():
95
+ if not STATE_FILE.exists():
96
+ return {}
97
+ try:
98
+ data = json.loads(STATE_FILE.read_text())
99
+ except Exception:
100
+ return {}
101
+ return data if isinstance(data, dict) else {}
102
+
103
+
104
+ def save_state(finish_at: datetime, duration_sec: int):
105
+ """終了予定時刻と総時間、現在時刻をキャッシュに保存"""
106
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
107
+ now = datetime.now()
108
+ payload = {
109
+ "finish_at": finish_at.isoformat(),
110
+ "duration_sec": int(duration_sec),
111
+ "last_saved_at": now.isoformat(),
112
+ }
113
+ existing = _read_state_payload()
114
+ last_finished = existing.get("last_finished")
115
+ if isinstance(last_finished, dict):
116
+ payload["last_finished"] = last_finished
117
+ STATE_FILE.write_text(json.dumps(payload))
118
+
119
+
120
+ def load_state():
121
+ """キャッシュから終了予定時刻と総時間を読み出す"""
122
+ data = _read_state_payload()
123
+ finish_at_raw = data.get("finish_at")
124
+ duration_raw = data.get("duration_sec")
125
+ if finish_at_raw is None or duration_raw is None:
126
+ return None
127
+ try:
128
+ finish_at = datetime.fromisoformat(finish_at_raw)
129
+ duration_sec = int(duration_raw)
130
+ except Exception:
131
+ return None
132
+ return finish_at, duration_sec
133
+
134
+
135
+ def save_last_finished(finish_at: datetime, duration_sec: int):
136
+ """直近に終了したタイマーの情報だけを保持"""
137
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
138
+ payload = {
139
+ "last_finished": {
140
+ "finish_at": finish_at.isoformat(),
141
+ "duration_sec": int(duration_sec),
142
+ }
143
+ }
144
+ STATE_FILE.write_text(json.dumps(payload))
145
+
146
+
147
+ def load_last_finished():
148
+ data = _read_state_payload()
149
+ last_finished = data.get("last_finished")
150
+ if not isinstance(last_finished, dict):
151
+ return None
152
+ finish_at_raw = last_finished.get("finish_at")
153
+ duration_raw = last_finished.get("duration_sec")
154
+ if finish_at_raw is None or duration_raw is None:
155
+ return None
156
+ try:
157
+ finish_at = datetime.fromisoformat(finish_at_raw)
158
+ duration_sec = int(duration_raw)
159
+ except Exception:
160
+ return None
161
+ return finish_at, duration_sec
162
+
163
+
164
+ # ------------------------------
165
+ # タイマー本体
166
+ # ------------------------------
167
+ def _parse_single_duration(duration_str: str):
168
+ """Parse duration from HH:MM:SS or AhBmCs style into (h, m, s)."""
169
+ duration_str = duration_str.strip()
170
+ if not duration_str:
171
+ raise ValueError
172
+
173
+ if ":" in duration_str:
174
+ parts = duration_str.split(":")
175
+ if len(parts) != 3:
176
+ raise ValueError
177
+ try:
178
+ h, m, s = map(int, parts)
179
+ except ValueError as exc:
180
+ raise ValueError from exc
181
+ if any(value < 0 for value in (h, m, s)):
182
+ raise ValueError
183
+ if h == m == s == 0:
184
+ raise ValueError
185
+ return h, m, s
186
+
187
+ normalized = duration_str.replace(" ", "").lower()
188
+ if not normalized:
189
+ raise ValueError
190
+
191
+ pos = 0
192
+ hours = minutes = seconds = 0
193
+ for match in DURATION_PATTERN.finditer(normalized):
194
+ if match.start() != pos:
195
+ raise ValueError
196
+ value = int(match.group(1))
197
+ unit = match.group(2).lower()
198
+ if unit == "h":
199
+ hours += value
200
+ elif unit == "m":
201
+ minutes += value
202
+ elif unit == "s":
203
+ seconds += value
204
+ pos = match.end()
205
+
206
+ if pos != len(normalized):
207
+ raise ValueError
208
+
209
+ if hours == minutes == seconds == 0:
210
+ raise ValueError
211
+
212
+ return hours, minutes, seconds
213
+
214
+
215
+ def _duration_to_seconds(hours: int, minutes: int, seconds: int) -> int:
216
+ return int(timedelta(hours=hours, minutes=minutes, seconds=seconds).total_seconds())
217
+
218
+
219
+ def parse_duration(duration_str: str):
220
+ """Parse duration; support remaining/total with a slash or a single duration.
221
+
222
+ Returns (remaining_seconds, total_seconds).
223
+ """
224
+ duration_str = duration_str.strip()
225
+ if not duration_str:
226
+ raise ValueError(INVALID_DURATION_MESSAGE)
227
+
228
+ fraction_parts = FRACTION_SPLIT_PATTERN.split(duration_str, maxsplit=1)
229
+ if len(fraction_parts) == 2:
230
+ remaining_raw, total_raw = fraction_parts
231
+ try:
232
+ rh, rm, rs = _parse_single_duration(remaining_raw)
233
+ th, tm, ts = _parse_single_duration(total_raw)
234
+ except ValueError:
235
+ raise ValueError(INVALID_DURATION_MESSAGE)
236
+
237
+ remaining_sec = _duration_to_seconds(rh, rm, rs)
238
+ total_sec = _duration_to_seconds(th, tm, ts)
239
+
240
+ if remaining_sec <= 0 or total_sec <= 0:
241
+ raise ValueError(
242
+ "Duration must be positive (parsed as remaining/total like 3h/5h)."
243
+ )
244
+ if remaining_sec > total_sec:
245
+ raise ValueError(
246
+ "Remaining duration cannot exceed total duration "
247
+ "(parsed as remaining/total like 3h/5h)."
248
+ )
249
+
250
+ return remaining_sec, total_sec
251
+
252
+ try:
253
+ h, m, s = _parse_single_duration(duration_str)
254
+ except ValueError:
255
+ raise ValueError(INVALID_DURATION_MESSAGE)
256
+ single_sec = _duration_to_seconds(h, m, s)
257
+ return single_sec, single_sec
258
+
259
+
260
+ def start_timer(
261
+ hours=0,
262
+ minutes=0,
263
+ seconds=0,
264
+ *,
265
+ one_line=False,
266
+ graph_only=False,
267
+ ):
268
+ console = _get_console(one_line=one_line, graph_only=graph_only)
269
+ try:
270
+ duration_sec = _duration_to_seconds(hours, minutes, seconds)
271
+ finish_at, duration_sec = _schedule_timer_seconds(duration_sec, duration_sec)
272
+ except ValueError as exc:
273
+ console.print(str(exc))
274
+ return
275
+
276
+ if not one_line and not graph_only:
277
+ console.print(
278
+ "Coffee cooldown started. "
279
+ f"Expires at {finish_at.strftime('%Y-%m-%d %H:%M:%S')}"
280
+ )
281
+ run_timer_loop(
282
+ finish_at,
283
+ duration_sec,
284
+ one_line=one_line,
285
+ graph_only=graph_only,
286
+ )
287
+
288
+
289
+ def run_timer_loop(
290
+ finish_at: datetime = None,
291
+ duration_sec: int = None,
292
+ *,
293
+ one_line: bool = False,
294
+ graph_only: bool = False,
295
+ ):
296
+ console = _get_console(one_line=one_line, graph_only=graph_only)
297
+ # resume 用に state から読み直すケース
298
+ if finish_at is None or duration_sec is None:
299
+ state = load_state()
300
+ if state is None:
301
+ console.print(NO_ACTIVE_TIMER_MESSAGE)
302
+ return
303
+ finish_at, duration_sec = state
304
+
305
+ now = datetime.now()
306
+ if (finish_at - now) <= timedelta(0):
307
+ try:
308
+ save_last_finished(finish_at, duration_sec)
309
+ except Exception:
310
+ pass
311
+ console.print(_select_expired_message(finish_at, duration_sec))
312
+ return
313
+
314
+ if one_line or graph_only:
315
+ _run_ascii_loop(
316
+ finish_at,
317
+ duration_sec,
318
+ graph_only=graph_only,
319
+ )
320
+ return
321
+
322
+ try:
323
+ _run_rich_loop(finish_at, duration_sec)
324
+
325
+ try:
326
+ save_last_finished(finish_at, duration_sec)
327
+ except Exception:
328
+ pass
329
+ console.print(_select_expired_message(finish_at, duration_sec))
330
+
331
+ except KeyboardInterrupt:
332
+ console.print("\nInterrupted by user. Timer state saved.")
333
+
334
+
335
+ def _run_rich_loop(finish_at: datetime, duration_sec: int):
336
+ last_saved_minute = None
337
+ progress = Progress(
338
+ TextColumn("{task.fields[remaining]}"),
339
+ BarColumn(bar_width=60),
340
+ transient=True,
341
+ console=COLORED_CONSOLE,
342
+ )
343
+
344
+ with progress:
345
+ task_id = progress.add_task(
346
+ "",
347
+ total=duration_sec,
348
+ remaining="--:--:--",
349
+ )
350
+
351
+ while True:
352
+ now = datetime.now()
353
+ remaining = finish_at - now
354
+ remaining_sec = int(remaining.total_seconds())
355
+
356
+ if remaining_sec <= 0:
357
+ progress.update(
358
+ task_id,
359
+ completed=0,
360
+ remaining="00:00:00",
361
+ )
362
+ break
363
+
364
+ completed = remaining_sec
365
+ remaining_str = _format_remaining(remaining_sec)
366
+
367
+ progress.update(
368
+ task_id,
369
+ completed=completed,
370
+ remaining=remaining_str,
371
+ )
372
+
373
+ if last_saved_minute != now.minute:
374
+ save_state(finish_at, duration_sec)
375
+ last_saved_minute = now.minute
376
+
377
+ time.sleep(1)
378
+
379
+
380
+ def _run_ascii_loop(
381
+ finish_at: datetime,
382
+ duration_sec: int,
383
+ *,
384
+ graph_only: bool = False,
385
+ ):
386
+ console = _get_console(one_line=True, graph_only=graph_only)
387
+ last_saved_minute = None
388
+ last_line_len = 0
389
+
390
+ try:
391
+ while True:
392
+ now = datetime.now()
393
+ remaining = finish_at - now
394
+ remaining_sec = int(remaining.total_seconds())
395
+
396
+ if remaining_sec <= 0:
397
+ break
398
+
399
+ line = _render_one_line(
400
+ remaining_sec,
401
+ duration_sec,
402
+ graph_only=graph_only,
403
+ )
404
+ pad = max(last_line_len - len(line), 0)
405
+ output = line + (" " * pad)
406
+ console.print(
407
+ output,
408
+ end="\r",
409
+ markup=False,
410
+ highlight=False,
411
+ )
412
+ last_line_len = len(line)
413
+
414
+ if last_saved_minute != now.minute:
415
+ save_state(finish_at, duration_sec)
416
+ last_saved_minute = now.minute
417
+
418
+ time.sleep(1)
419
+
420
+ except KeyboardInterrupt:
421
+ console.print("\nInterrupted by user. Timer state saved.")
422
+ return
423
+
424
+ # Clear the current line before printing the final message.
425
+ if last_line_len:
426
+ console.print(" " * last_line_len, end="\r")
427
+
428
+ try:
429
+ save_last_finished(finish_at, duration_sec)
430
+ except Exception:
431
+ pass
432
+
433
+ console.print(_select_expired_message(finish_at, duration_sec))
434
+
435
+
436
+ def _print_snapshot_status(
437
+ finish_at: datetime,
438
+ duration_sec: int,
439
+ *,
440
+ one_line: bool = False,
441
+ graph_only: bool = False,
442
+ ):
443
+ console = _get_console(one_line=one_line, graph_only=graph_only)
444
+ remaining_sec = int((finish_at - datetime.now()).total_seconds())
445
+ if remaining_sec <= 0:
446
+ try:
447
+ save_last_finished(finish_at, duration_sec)
448
+ except Exception:
449
+ pass
450
+ console.print(_select_expired_message(finish_at, duration_sec))
451
+ return
452
+
453
+ if graph_only:
454
+ line = _render_one_line(
455
+ remaining_sec,
456
+ duration_sec,
457
+ graph_only=True,
458
+ )
459
+ console.print(line, markup=False)
460
+ return
461
+
462
+ if one_line:
463
+ line = _render_one_line(
464
+ remaining_sec,
465
+ duration_sec,
466
+ graph_only=False,
467
+ )
468
+ console.print(line, markup=False)
469
+ return
470
+
471
+ expires_at = finish_at.strftime("%Y-%m-%d %H:%M:%S")
472
+ remaining_str = _format_remaining(remaining_sec)
473
+ bar_line = _render_one_line(
474
+ remaining_sec,
475
+ duration_sec,
476
+ graph_only=True,
477
+ )
478
+ console.print(f"Remaining: {remaining_str}")
479
+ console.print(f"Expires at: {expires_at}")
480
+ console.print(bar_line, markup=False)
481
+
482
+
483
+ def _format_remaining(remaining_sec: int) -> str:
484
+ h = remaining_sec // 3600
485
+ m = (remaining_sec % 3600) // 60
486
+ s = remaining_sec % 60
487
+ return f"{h:02d}:{m:02d}:{s:02d}"
488
+
489
+
490
+ def _render_one_line(
491
+ remaining_sec: int,
492
+ duration_sec: int,
493
+ *,
494
+ graph_only: bool = False,
495
+ ) -> str:
496
+ remaining_str = _format_remaining(max(remaining_sec, 0))
497
+ segments = ONE_LINE_BAR_WIDTH
498
+ if duration_sec <= 0:
499
+ bar = BAR_EMPTY_CHAR * segments
500
+ else:
501
+ ratio = max(0.0, min(remaining_sec / duration_sec, 1.0))
502
+ filled_segments = int(ratio * segments + 0.5)
503
+ filled_segments = max(0, min(filled_segments, segments))
504
+ empty_segments = segments - filled_segments
505
+ bar = (BAR_FILLED_CHAR * filled_segments) + (BAR_EMPTY_CHAR * empty_segments)
506
+ if graph_only:
507
+ return f"{bar}"
508
+ return f"{remaining_str} {bar}"
509
+
510
+
511
+ def resume_timer(*, one_line=False, graph_only=False):
512
+ console = _get_console(one_line=one_line, graph_only=graph_only)
513
+ state = load_state()
514
+ if state is None:
515
+ last_finished = load_last_finished()
516
+ if last_finished is not None:
517
+ console.print(_select_expired_message(*last_finished))
518
+ else:
519
+ if one_line or graph_only:
520
+ console.print(_select_expired_message(None, None))
521
+ else:
522
+ console.print(NO_ACTIVE_TIMER_MESSAGE)
523
+ return
524
+
525
+ finish_at, duration_sec = state
526
+ if finish_at <= datetime.now():
527
+ try:
528
+ save_last_finished(finish_at, duration_sec)
529
+ except Exception:
530
+ pass
531
+ console.print(_select_expired_message(finish_at, duration_sec))
532
+ return
533
+ if not one_line and not graph_only:
534
+ console.print(
535
+ f"Resuming cooldown. Expires at {finish_at.strftime('%Y-%m-%d %H:%M:%S')}"
536
+ )
537
+ run_timer_loop(
538
+ finish_at,
539
+ duration_sec,
540
+ one_line=one_line,
541
+ graph_only=graph_only,
542
+ )
543
+
544
+
545
+ # ------------------------------
546
+ # エントリポイント
547
+ # ------------------------------
548
+ def main(argv=None):
549
+ args = _parse_args(argv)
550
+ resolved = _resolve_timer_state(args)
551
+ if resolved is None:
552
+ return
553
+ finish_at, duration_sec, new_timer_started = resolved
554
+
555
+ if args.run:
556
+ _run_live_mode(
557
+ args,
558
+ finish_at,
559
+ duration_sec,
560
+ new_timer_started,
561
+ )
562
+ return
563
+
564
+ _print_snapshot_status(
565
+ finish_at,
566
+ duration_sec,
567
+ one_line=args.one_line,
568
+ graph_only=args.graph_only,
569
+ )
570
+
571
+
572
+ def _parse_args(argv=None):
573
+ import argparse
574
+
575
+ parser = argparse.ArgumentParser(description="Coffee cooldown timer (rich version)")
576
+ parser.add_argument(
577
+ "duration",
578
+ nargs="?",
579
+ metavar="DURATION",
580
+ help=(
581
+ "Set a new timer (e.g. 2h, 15m30s, 0:25:00, or remaining/total like 3h/5h). "
582
+ "Omit to resume."
583
+ ),
584
+ )
585
+ parser.add_argument(
586
+ "--version",
587
+ action="version",
588
+ version=f"%(prog)s {__version__}",
589
+ )
590
+ parser.add_argument(
591
+ "--one-line",
592
+ action="store_true",
593
+ help="Use the single-line ASCII format (time + bar).",
594
+ )
595
+ parser.add_argument(
596
+ "--graph-only",
597
+ action="store_true",
598
+ help="Show only the ASCII bar (no time).",
599
+ )
600
+ parser.add_argument(
601
+ "--run",
602
+ action="store_true",
603
+ help="Keep updating continuously until the timer expires.",
604
+ )
605
+ return parser.parse_args(argv)
606
+
607
+
608
+ def _resolve_timer_state(args):
609
+ console = _get_console(one_line=args.one_line, graph_only=args.graph_only)
610
+ finish_at = None
611
+ duration_sec = None
612
+ new_timer_started = False
613
+
614
+ if args.duration:
615
+ try:
616
+ remaining_sec, total_sec = parse_duration(args.duration)
617
+ except ValueError as exc:
618
+ message = str(exc) if str(exc) else INVALID_DURATION_MESSAGE
619
+ console.print(message)
620
+ return None
621
+ try:
622
+ finish_at, duration_sec = _schedule_timer_seconds(remaining_sec, total_sec)
623
+ except ValueError as exc:
624
+ console.print(str(exc))
625
+ return None
626
+ new_timer_started = True
627
+ else:
628
+ state = load_state()
629
+ if state is None:
630
+ if args.run:
631
+ console.print(NO_ACTIVE_TIMER_MESSAGE)
632
+ else:
633
+ last_finished = load_last_finished()
634
+ if last_finished is not None:
635
+ console.print(_select_expired_message(*last_finished))
636
+ else:
637
+ if args.one_line or args.graph_only:
638
+ console.print(_select_expired_message(None, None))
639
+ else:
640
+ console.print(NO_ACTIVE_TIMER_MESSAGE)
641
+ return None
642
+ finish_at, duration_sec = state
643
+
644
+ return finish_at, duration_sec, new_timer_started
645
+
646
+
647
+ def _run_live_mode(args, finish_at, duration_sec, new_timer_started):
648
+ console = _get_console(
649
+ one_line=args.one_line,
650
+ graph_only=args.graph_only,
651
+ )
652
+ if finish_at > datetime.now():
653
+ if new_timer_started:
654
+ console.print(
655
+ "Coffee cooldown started. "
656
+ f"Expires at {finish_at.strftime('%Y-%m-%d %H:%M:%S')}"
657
+ )
658
+ else:
659
+ console.print(
660
+ "Resuming cooldown. "
661
+ f"Expires at {finish_at.strftime('%Y-%m-%d %H:%M:%S')}"
662
+ )
663
+ run_timer_loop(
664
+ finish_at,
665
+ duration_sec,
666
+ one_line=args.one_line,
667
+ graph_only=args.graph_only,
668
+ )
669
+
670
+
671
+ if __name__ == "__main__":
672
+ main()
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: decafe-timer
3
+ Version: 0.4.3
4
+ Project-URL: Documentation, https://github.com/Toshihiro Kamiya/decafe-timer#readme
5
+ Project-URL: Issues, https://github.com/Toshihiro Kamiya/decafe-timer/issues
6
+ Project-URL: Source, https://github.com/Toshihiro Kamiya/decafe-timer
7
+ Author-email: Toshihiro Kamiya <kamiya@mbj.nifty.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE.txt
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: Implementation :: CPython
16
+ Requires-Python: >=3.8
17
+ Requires-Dist: appdirs
18
+ Requires-Dist: rich
19
+ Description-Content-Type: text/markdown
20
+
21
+ # decafe-timer
22
+
23
+ [![PyPI - Version](https://img.shields.io/pypi/v/decafe-timer.svg)](https://pypi.org/project/decafe-timer)
24
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/decafe-timer.svg)](https://pypi.org/project/decafe-timer)
25
+
26
+ -----
27
+
28
+ ## Table of Contents
29
+
30
+ - [Installation](#installation)
31
+ - [Usage](#usage)
32
+ - [License](#license)
33
+
34
+ ## Installation
35
+
36
+ ```console
37
+ pip install decafe-timer
38
+ ```
39
+
40
+ ## Usage
41
+
42
+ The CLI revolves around three ideas:
43
+
44
+ 1. Passing a duration creates a new cooldown; omitting it resumes whatever is already running. You can use single durations (`2h`, `15m30s`, `0:45:00`) or a remaining/total pair like `3h/5h` (spaces around `/` are allowed, and mixed formats like `3h/4:50:00` work too).
45
+ 2. `--run` decides whether to keep the Rich UI updating until the cooldown expires. Without `--run`, the command prints the current status once and exits.
46
+ 3. Style flags pick the ASCII layout: default multi-line (`Remaining` / `Expires at` + bar), `--one-line` (`HH:MM:SS 𝅛𝅛𝅛𝅚𝅚…`), or `--graph-only` (just the bar with no time stamp). They’re accepted on any invocation; when paired with `--run`, the live updates switch to that ASCII style instead of the Rich progress bar. Snapshots print `[You may drink coffee now.]` once the timer finishes.
47
+
48
+ ```console
49
+ decafe-timer 45m # start a new timer, print one snapshot
50
+ decafe-timer 3h/5h # start with 3h remaining out of a 5h total
51
+ decafe-timer # resume the latest timer, one snapshot
52
+ decafe-timer --run 45m # start a new timer and watch it count down
53
+ decafe-timer --run # resume the Rich UI for an active timer
54
+ decafe-timer --run --one-line 10m # live ASCII updates instead of Rich
55
+ decafe-timer --graph-only # snapshot with the ASCII bar only
56
+ decafe-timer --version # show the current version
57
+ ```
58
+
59
+ ## License
60
+
61
+ `decafe-timer` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,8 @@
1
+ decafe_timer/__about__.py,sha256=TQ-uFlqREnTaUs1BIstUXNi-RbUGM4yYvYUKhQVK9SY,134
2
+ decafe_timer/__init__.py,sha256=qYYWqFTmjMNdoaXtn7Gm0ViSlKMXAp0YJPZhJ0PITJw,135
3
+ decafe_timer/main.py,sha256=eDWlpwnwKdCR3urbch_yHxYzMY_L-4Sppq5xeh_aq8U,19928
4
+ decafe_timer-0.4.3.dist-info/METADATA,sha256=TUeUIGy7ru-p-wHgt8vjLn2qb7G-f4C13kSjS3lR1S4,2757
5
+ decafe_timer-0.4.3.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ decafe_timer-0.4.3.dist-info/entry_points.txt,sha256=aX-QINfkbeC9PyuSiQH57qe39euSc9DoLnwkgTWpnRg,51
7
+ decafe_timer-0.4.3.dist-info/licenses/LICENSE.txt,sha256=q-cJYG_K766eXSxQ7txWcWQ6nS2OF6c3HTVLesHbesU,1104
8
+ decafe_timer-0.4.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ decafe-timer = decafe_timer:main
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Toshihiro Kamiya <kamiya@mbj.nifty.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.