Skip to content

Commit 94ad496

Browse files
authored
Merge pull request #17 from jepler/narrow-screens
2 parents 364cd0c + f5bcf34 commit 94ad496

File tree

2 files changed

+174
-17
lines changed

2 files changed

+174
-17
lines changed

src/ttotp/TinyProgress.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/python3
2+
3+
# SPDX-FileCopyrightText: 2025 Jeff Epler
4+
# SPDX-FileCopyrightText: 2021 Will McGugan
5+
#
6+
# SPDX-License-Identifier: MIT
7+
8+
from textual.app import ComposeResult, RenderResult
9+
from textual.widgets._progress_bar import ProgressBar, Bar
10+
from rich.text import Text
11+
12+
13+
class OneCellBar(Bar):
14+
"""The bar portion of the tiny progress bar."""
15+
16+
BARS = "▁▂▃▄▅▆▇"
17+
SHADES = "█▓▒░▒▓"
18+
19+
DEFAULT_CSS = """
20+
OneCellBar {
21+
width: 1;
22+
height: 1;
23+
24+
&> .bar--bar {
25+
color: $primary;
26+
background: $surface;
27+
}
28+
&> .bar--indeterminate {
29+
color: $error;
30+
background: $surface;
31+
}
32+
&> .bar--complete {
33+
color: $success;
34+
background: $surface;
35+
}
36+
}
37+
"""
38+
39+
def render(self) -> RenderResult:
40+
if self.percentage is None:
41+
return self.render_indeterminate()
42+
else:
43+
return self.render_determinate(self.percentage)
44+
45+
def render_determinate(self, percentage: float) -> RenderResult:
46+
bar_style = (
47+
self.get_component_rich_style("bar--bar")
48+
if percentage < 1
49+
else self.get_component_rich_style("bar--complete")
50+
)
51+
i = self.percentage_to_index(percentage)
52+
return Text(self.BARS[i], style=bar_style)
53+
54+
def watch_percentage(self, percentage: float | None) -> None:
55+
"""Manage the timer that enables the indeterminate bar animation."""
56+
if percentage is not None:
57+
self.auto_refresh = None
58+
else:
59+
self.auto_refresh = 1 # every second
60+
61+
def render_indeterminate(self) -> RenderResult:
62+
bar_style = self.get_component_rich_style("bar--indeterminate")
63+
phase = round(self._clock.time) % len(self.SHADES)
64+
i = self.SHADES[phase]
65+
return Text(i, style=bar_style)
66+
67+
def percentage_to_index(self, percentage: float) -> int:
68+
p = max(0, min(1, percentage))
69+
i = round(p * (len(self.BARS) - 1))
70+
return i
71+
72+
def _validate_percentage(self, percentage: float | None) -> float | None:
73+
if percentage is None:
74+
return None
75+
return self.percentage_to_index(percentage) / (len(self.BARS) - 1)
76+
77+
78+
class TinyProgress(ProgressBar):
79+
def compose(self) -> ComposeResult:
80+
if self.show_bar:
81+
yield (
82+
OneCellBar(id="bar", clock=self._clock)
83+
.data_bind(ProgressBar.percentage)
84+
.data_bind(ProgressBar.gradient)
85+
)

src/ttotp/__main__.py

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from dataclasses import dataclass, field
88

9+
import signal
910
import time
1011
import hashlib
1112
import pathlib
@@ -19,7 +20,7 @@
1920
from textual.app import App, ComposeResult
2021
from textual.events import Key, MouseDown, MouseUp, MouseScrollDown, MouseScrollUp
2122
from textual.widget import Widget
22-
from textual.widgets import Label, Footer, ProgressBar, Button, Input
23+
from textual.widgets import Label, Footer, Button, Input
2324
from textual.binding import Binding
2425
from textual.containers import VerticalScroll, Horizontal
2526
from textual.css.query import DOMQuery
@@ -30,11 +31,20 @@
3031
import platformdirs
3132
import tomllib
3233

34+
from .TinyProgress import TinyProgress as ProgressBar
35+
3336
from typing import TypeGuard # use `typing_extensions` for Python 3.9 and below
3437

3538
# workaround for pyperclip being un-typed
3639
if TYPE_CHECKING:
3740

41+
class CopyProcessor:
42+
def do_copy(self, data: str) -> None:
43+
...
44+
45+
def do_clear_copy(self) -> bool:
46+
...
47+
3848
def pyperclip_paste() -> str:
3949
...
4050

@@ -45,6 +55,63 @@ def pyperclip_copy(data: str) -> None:
4555
from pyperclip import copy as pyperclip_copy
4656

4757

58+
def command_exists(s: str) -> bool:
59+
status = subprocess.run(
60+
["which", s],
61+
stdout=subprocess.DEVNULL,
62+
stderr=subprocess.DEVNULL,
63+
stdin=subprocess.DEVNULL,
64+
)
65+
return status.returncode == 0
66+
67+
68+
@dataclass
69+
class PyperclipCopyProcessor:
70+
copied: str = ""
71+
72+
def do_copy(self, data: str) -> None:
73+
self.copied = data
74+
75+
def do_clear_copy(self) -> bool:
76+
if self.copied and pyperclip_paste() == self.copied:
77+
pyperclip_copy("")
78+
return True
79+
return False
80+
81+
82+
@dataclass
83+
class XClipCopyProcessor:
84+
process: subprocess.Popen[bytes] | None = None
85+
86+
def do_copy(self, data: str) -> None:
87+
self.do_clear_copy()
88+
self.process = subprocess.Popen(
89+
["xclip", "-verbose", "-sel", "c"],
90+
stdout=subprocess.DEVNULL,
91+
stderr=subprocess.DEVNULL,
92+
stdin=subprocess.PIPE,
93+
)
94+
assert self.process.stdin is not None # mypy worries about this at night
95+
self.process.stdin.write(data.encode("utf-8"))
96+
self.process.stdin.close()
97+
98+
def do_clear_copy(self) -> bool:
99+
if self.process is None:
100+
return False
101+
self.process.send_signal(signal.SIGINT)
102+
returncode = self.process.wait(0.1)
103+
if returncode is None:
104+
self.process.send_signal(signal.SIGKILL)
105+
returncode = self.process.wait(0.1)
106+
self.process = None
107+
return True
108+
109+
110+
copy_processor = (
111+
XClipCopyProcessor() if command_exists("xclip") else PyperclipCopyProcessor()
112+
)
113+
114+
48115
def is_str_list(val: Any) -> TypeGuard[list[str]]:
49116
"""Determines whether all objects in the list are strings"""
50117
if not isinstance(val, list):
@@ -261,21 +328,21 @@ def replace_escape_sequence(m: re.Match[str]) -> str:
261328

262329

263330
class TTOTP(App[None]):
331+
HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (60, "-normal"), (120, "-very-wide")]
264332
CSS = """
265333
VerticalScroll { min-height: 1; }
266-
.otp-progress { width: 12; }
267334
.otp-value { width: 9; }
268335
.otp-hidden { display: none; }
269336
.otp-name { text-wrap: nowrap; text-overflow: ellipsis; }
270337
.otp-name:focus { background: red; }
271338
TOTPLabel { width: 1fr; height: 1; padding: 0 1; }
272339
Horizontal:focus-within { background: $primary-background; }
273-
Bar > .bar--bar { color: $success; }
274-
Bar { width: 1fr; }
340+
OneCellBar > .bar--bar { color: $success; }
275341
Button { border: none; height: 1; width: 3; min-width: 4 }
276342
Horizontal { height: 1; }
277343
Input { border: none; height: 1; width: 1fr; }
278344
Input.error { background: $error; }
345+
.-narrow TOTPButton { display: None; }
279346
"""
280347

281348
BINDINGS = [
@@ -290,7 +357,7 @@ def __init__(
290357
self.tokens = tokens
291358
self.otp_data: list[TOTPData] = []
292359
self.timer: Timer | None = None
293-
self.clear_clipboard_time: Timer | None = None
360+
self.clear_clipboard_timer: Timer | None = None
294361
self.exit_time: Timer | None = None
295362
self.warn_exit_time: Timer | None = None
296363
self.timeout: int | float | None = timeout
@@ -299,9 +366,6 @@ def __init__(
299366
def on_mount(self) -> None:
300367
self.timer_func()
301368
self.timer = self.set_interval(1, self.timer_func)
302-
self.clear_clipboard_timer = self.set_timer(
303-
30, self.clear_clipboard_func, pause=True
304-
)
305369
if self.timeout:
306370
self.exit_time = self.set_timer(self.timeout, self.action_quit)
307371
warn_timeout = max(self.timeout / 2, self.timeout - 10)
@@ -322,9 +386,8 @@ def warn_quit(self) -> None:
322386
self.notify("Will exit soon due to inactivity", title="Auto-exit")
323387

324388
def clear_clipboard_func(self) -> None:
325-
if pyperclip_paste() == self.copied:
389+
if copy_processor.do_clear_copy():
326390
self.notify("Clipboard cleared", title="")
327-
pyperclip_copy("")
328391

329392
def timer_func(self) -> None:
330393
now = time.time()
@@ -353,12 +416,16 @@ def action_copy(self) -> None:
353416
if widget is not None:
354417
otp = cast(TOTPLabel, widget).otp
355418
code = otp.totp.now()
356-
pyperclip_copy(code)
357-
self.copied = code
358-
self.clear_clipboard_timer.reset()
359-
self.clear_clipboard_timer.resume()
419+
copy_processor.do_copy(code)
420+
if self.clear_clipboard_timer is not None:
421+
self.clear_clipboard_timer.pause()
360422

361-
self.notify("Code copied", title="")
423+
now = time.time()
424+
interval = otp.totp.interval
425+
_, progress = divmod(now, interval)
426+
left = 1.5 * interval - progress
427+
self.clear_clipboard_timer = self.set_timer(left, self.clear_clipboard_func)
428+
self.notify(f"Will clear in {left:.1f}s", title="Code Copied")
362429

363430
def on_button_pressed(self, event: Button.Pressed) -> None:
364431
button = cast(TOTPButton, event.button)
@@ -421,7 +488,7 @@ def on_input_submitted(self, event: Input.Changed) -> None:
421488
"--profile",
422489
type=str,
423490
default=None,
424-
help="Profile to use within the configuration file",
491+
help="Profile to use within the configuration file (case sensitive). Use `--profile list` to list profiles",
425492
)
426493
def main(config: pathlib.Path, profile: str) -> None:
427494
def config_hint(extra: str) -> None:
@@ -454,11 +521,16 @@ def config_hint(extra: str) -> None:
454521
with open(config, "rb") as f:
455522
config_data = tomllib.load(f)
456523

524+
if profile == "list":
525+
print("Profile names:" + " ".join(config_data.keys()))
526+
raise SystemExit(0)
527+
457528
if profile:
458529
profile_data = config_data.get(profile, None)
459530
if profile_data is None:
460531
config_hint(f"The profile {profile!r} file does not exist.")
461-
config_data.update(profile_data)
532+
else:
533+
config_data.update(profile_data)
462534

463535
otp_command = config_data.get("otp-command")
464536
if otp_command is None:

0 commit comments

Comments
 (0)