Merge pull request #17 from jepler/narrow-screens

This commit is contained in:
Jeff Epler 2025-08-17 10:41:19 -05:00 committed by GitHub
commit 94ad496cdb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 174 additions and 17 deletions

85
src/ttotp/TinyProgress.py Normal file
View file

@ -0,0 +1,85 @@
#!/usr/bin/python3
# SPDX-FileCopyrightText: 2025 Jeff Epler
# SPDX-FileCopyrightText: 2021 Will McGugan
#
# SPDX-License-Identifier: MIT
from textual.app import ComposeResult, RenderResult
from textual.widgets._progress_bar import ProgressBar, Bar
from rich.text import Text
class OneCellBar(Bar):
"""The bar portion of the tiny progress bar."""
BARS = "▁▂▃▄▅▆▇"
SHADES = "█▓▒░▒▓"
DEFAULT_CSS = """
OneCellBar {
width: 1;
height: 1;
&> .bar--bar {
color: $primary;
background: $surface;
}
&> .bar--indeterminate {
color: $error;
background: $surface;
}
&> .bar--complete {
color: $success;
background: $surface;
}
}
"""
def render(self) -> RenderResult:
if self.percentage is None:
return self.render_indeterminate()
else:
return self.render_determinate(self.percentage)
def render_determinate(self, percentage: float) -> RenderResult:
bar_style = (
self.get_component_rich_style("bar--bar")
if percentage < 1
else self.get_component_rich_style("bar--complete")
)
i = self.percentage_to_index(percentage)
return Text(self.BARS[i], style=bar_style)
def watch_percentage(self, percentage: float | None) -> None:
"""Manage the timer that enables the indeterminate bar animation."""
if percentage is not None:
self.auto_refresh = None
else:
self.auto_refresh = 1 # every second
def render_indeterminate(self) -> RenderResult:
bar_style = self.get_component_rich_style("bar--indeterminate")
phase = round(self._clock.time) % len(self.SHADES)
i = self.SHADES[phase]
return Text(i, style=bar_style)
def percentage_to_index(self, percentage: float) -> int:
p = max(0, min(1, percentage))
i = round(p * (len(self.BARS) - 1))
return i
def _validate_percentage(self, percentage: float | None) -> float | None:
if percentage is None:
return None
return self.percentage_to_index(percentage) / (len(self.BARS) - 1)
class TinyProgress(ProgressBar):
def compose(self) -> ComposeResult:
if self.show_bar:
yield (
OneCellBar(id="bar", clock=self._clock)
.data_bind(ProgressBar.percentage)
.data_bind(ProgressBar.gradient)
)

View file

@ -6,6 +6,7 @@
from dataclasses import dataclass, field
import signal
import time
import hashlib
import pathlib
@ -19,7 +20,7 @@ from textual.fuzzy import Matcher
from textual.app import App, ComposeResult
from textual.events import Key, MouseDown, MouseUp, MouseScrollDown, MouseScrollUp
from textual.widget import Widget
from textual.widgets import Label, Footer, ProgressBar, Button, Input
from textual.widgets import Label, Footer, Button, Input
from textual.binding import Binding
from textual.containers import VerticalScroll, Horizontal
from textual.css.query import DOMQuery
@ -30,11 +31,20 @@ import pyotp
import platformdirs
import tomllib
from .TinyProgress import TinyProgress as ProgressBar
from typing import TypeGuard # use `typing_extensions` for Python 3.9 and below
# workaround for pyperclip being un-typed
if TYPE_CHECKING:
class CopyProcessor:
def do_copy(self, data: str) -> None:
...
def do_clear_copy(self) -> bool:
...
def pyperclip_paste() -> str:
...
@ -45,6 +55,63 @@ else:
from pyperclip import copy as pyperclip_copy
def command_exists(s: str) -> bool:
status = subprocess.run(
["which", s],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
return status.returncode == 0
@dataclass
class PyperclipCopyProcessor:
copied: str = ""
def do_copy(self, data: str) -> None:
self.copied = data
def do_clear_copy(self) -> bool:
if self.copied and pyperclip_paste() == self.copied:
pyperclip_copy("")
return True
return False
@dataclass
class XClipCopyProcessor:
process: subprocess.Popen[bytes] | None = None
def do_copy(self, data: str) -> None:
self.do_clear_copy()
self.process = subprocess.Popen(
["xclip", "-verbose", "-sel", "c"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.PIPE,
)
assert self.process.stdin is not None # mypy worries about this at night
self.process.stdin.write(data.encode("utf-8"))
self.process.stdin.close()
def do_clear_copy(self) -> bool:
if self.process is None:
return False
self.process.send_signal(signal.SIGINT)
returncode = self.process.wait(0.1)
if returncode is None:
self.process.send_signal(signal.SIGKILL)
returncode = self.process.wait(0.1)
self.process = None
return True
copy_processor = (
XClipCopyProcessor() if command_exists("xclip") else PyperclipCopyProcessor()
)
def is_str_list(val: Any) -> TypeGuard[list[str]]:
"""Determines whether all objects in the list are strings"""
if not isinstance(val, list):
@ -261,21 +328,21 @@ def search_preprocess(s: str) -> str:
class TTOTP(App[None]):
HORIZONTAL_BREAKPOINTS = [(0, "-narrow"), (60, "-normal"), (120, "-very-wide")]
CSS = """
VerticalScroll { min-height: 1; }
.otp-progress { width: 12; }
.otp-value { width: 9; }
.otp-hidden { display: none; }
.otp-name { text-wrap: nowrap; text-overflow: ellipsis; }
.otp-name:focus { background: red; }
TOTPLabel { width: 1fr; height: 1; padding: 0 1; }
Horizontal:focus-within { background: $primary-background; }
Bar > .bar--bar { color: $success; }
Bar { width: 1fr; }
OneCellBar > .bar--bar { color: $success; }
Button { border: none; height: 1; width: 3; min-width: 4 }
Horizontal { height: 1; }
Input { border: none; height: 1; width: 1fr; }
Input.error { background: $error; }
.-narrow TOTPButton { display: None; }
"""
BINDINGS = [
@ -290,7 +357,7 @@ class TTOTP(App[None]):
self.tokens = tokens
self.otp_data: list[TOTPData] = []
self.timer: Timer | None = None
self.clear_clipboard_time: Timer | None = None
self.clear_clipboard_timer: Timer | None = None
self.exit_time: Timer | None = None
self.warn_exit_time: Timer | None = None
self.timeout: int | float | None = timeout
@ -299,9 +366,6 @@ class TTOTP(App[None]):
def on_mount(self) -> None:
self.timer_func()
self.timer = self.set_interval(1, self.timer_func)
self.clear_clipboard_timer = self.set_timer(
30, self.clear_clipboard_func, pause=True
)
if self.timeout:
self.exit_time = self.set_timer(self.timeout, self.action_quit)
warn_timeout = max(self.timeout / 2, self.timeout - 10)
@ -322,9 +386,8 @@ class TTOTP(App[None]):
self.notify("Will exit soon due to inactivity", title="Auto-exit")
def clear_clipboard_func(self) -> None:
if pyperclip_paste() == self.copied:
if copy_processor.do_clear_copy():
self.notify("Clipboard cleared", title="")
pyperclip_copy("")
def timer_func(self) -> None:
now = time.time()
@ -353,12 +416,16 @@ class TTOTP(App[None]):
if widget is not None:
otp = cast(TOTPLabel, widget).otp
code = otp.totp.now()
pyperclip_copy(code)
self.copied = code
self.clear_clipboard_timer.reset()
self.clear_clipboard_timer.resume()
copy_processor.do_copy(code)
if self.clear_clipboard_timer is not None:
self.clear_clipboard_timer.pause()
self.notify("Code copied", title="")
now = time.time()
interval = otp.totp.interval
_, progress = divmod(now, interval)
left = 1.5 * interval - progress
self.clear_clipboard_timer = self.set_timer(left, self.clear_clipboard_func)
self.notify(f"Will clear in {left:.1f}s", title="Code Copied")
def on_button_pressed(self, event: Button.Pressed) -> None:
button = cast(TOTPButton, event.button)
@ -421,7 +488,7 @@ class TTOTP(App[None]):
"--profile",
type=str,
default=None,
help="Profile to use within the configuration file",
help="Profile to use within the configuration file (case sensitive). Use `--profile list` to list profiles",
)
def main(config: pathlib.Path, profile: str) -> None:
def config_hint(extra: str) -> None:
@ -454,10 +521,15 @@ multiple profiles as configuration file sections, and select one with
with open(config, "rb") as f:
config_data = tomllib.load(f)
if profile == "list":
print("Profile names:" + " ".join(config_data.keys()))
raise SystemExit(0)
if profile:
profile_data = config_data.get(profile, None)
if profile_data is None:
config_hint(f"The profile {profile!r} file does not exist.")
else:
config_data.update(profile_data)
otp_command = config_data.get("otp-command")