66
77from dataclasses import dataclass , field
88
9+ import signal
910import time
1011import hashlib
1112import pathlib
1920from textual .app import App , ComposeResult
2021from textual .events import Key , MouseDown , MouseUp , MouseScrollDown , MouseScrollUp
2122from textual .widget import Widget
22- from textual .widgets import Label , Footer , ProgressBar , Button , Input
23+ from textual .widgets import Label , Footer , Button , Input
2324from textual .binding import Binding
2425from textual .containers import VerticalScroll , Horizontal
2526from textual .css .query import DOMQuery
3031import platformdirs
3132import tomllib
3233
34+ from .TinyProgress import TinyProgress as ProgressBar
35+
3336from typing import TypeGuard # use `typing_extensions` for Python 3.9 and below
3437
3538# workaround for pyperclip being un-typed
3639if 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+
48115def 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
263330class 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)
426493def 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