From a216a321b498c1b2b451bfeddac1d14c941e5329 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Tue, 17 Feb 2026 01:01:45 +1100 Subject: [PATCH 01/10] more random --- laser_prynter/pbar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 5535ac8..35cb861 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -186,6 +186,6 @@ def __exit__(self, _exc_type: type, _exc_val: BaseException, _exc_tb: type) -> N if __name__ == '__main__': with PBar(200, *PBar.randgrad()) as pbar: for i in range(200): - time.sleep(randint(int(0.01 * 100), int(0.1 * 100)) / 100) - print(f'-> {i}') + time.sleep(randint(int(0.01 * 100), int(0.2 * 100)) / 100) + print(f'-> {i}', flush=True) pbar.update(1) From 2debd6c4da08e6277864514323ab97d9fe9d0333 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Tue, 17 Feb 2026 01:27:10 +1100 Subject: [PATCH 02/10] handle sigint cleanly --- laser_prynter/pbar.py | 58 ++++++++++++++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 35cb861..2cdfc7e 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -2,9 +2,11 @@ from __future__ import annotations -import shutil +import os +import signal import sys import time +import types from random import randint from typing import Iterator, NamedTuple @@ -22,8 +24,20 @@ def indexes(t: int, w: int) -> Iterator[int]: def _print_to_terminal(s: str) -> None: 'Helper function to perform ANSI escape sequences.' - sys.stdout.write(s) - sys.stdout.flush() + sys.stderr.write(s) + sys.stderr.flush() + + +def _get_terminal_size() -> tuple[int, int]: + 'Get terminal size (width, height)' + try: + size = os.get_terminal_size(1) + except OSError: + try: + size = os.get_terminal_size(2) + except OSError: + return (80, 24) + return (size.columns, size.lines) class RGB(NamedTuple): @@ -37,13 +51,14 @@ class RGB(NamedTuple): class PBar: def __init__(self, total: int, c1: RGB = DEFAULT_C1, c2: RGB = DEFAULT_C2): - self.t = total - self.w = shutil.get_terminal_size().columns - self.h = shutil.get_terminal_size().lines + self.w, self.h = _get_terminal_size() self.c1, self.c2 = c1, c2 - self.curr = 0 - self.progress = 0 # Track logical progress out of total + + self.t = total + self.curr, self.progress = 0, 0 + self.start_time = time.time() + if self.t > self.w: self.g = list(interp_xyz(c1, c2, self.t + 1)) else: @@ -51,6 +66,14 @@ def __init__(self, total: int, c1: RGB = DEFAULT_C1, c2: RGB = DEFAULT_C2): self._iter_pbar = iter(self._pbar()) + self.is_exiting = False + signal.signal(signal.SIGINT, self.sigint_handler) + + def sigint_handler(self, _signum: int, _frame: types.FrameType | None) -> None: + self.is_exiting = True + self._reset_terminal() + sys.exit(0) + @staticmethod def randgrad() -> tuple[RGB, RGB]: rgb1 = RGB(randint(0, 255), randint(0, 255), randint(0, 255)) @@ -167,19 +190,26 @@ def __enter__(self) -> PBar: self._print_info() return self + @staticmethod + def _reset_terminal() -> None: + w, h = _get_terminal_size() + _print_to_terminal( + '\x1b[?25h' # show cursor + f'\x1b[0;{h}r' # reset margins + f'\x1b[{h};1000H' # move to bottom line + '\n' + ) + def __exit__(self, _exc_type: type, _exc_val: BaseException, _exc_tb: type) -> None: # TODO: this is to ensure that the bar draws the full width. it should have done this already? + if self.is_exiting: + return while True: try: next(self) except StopIteration: break - _print_to_terminal( - '\x1b[?25h' # show cursor - f'\x1b[0;{self.h}r' # reset margins - f'\x1b[{self.h};0H' # move to bottom line - '\n' - ) + self._reset_terminal() self.curr = self.t From 649fdd53e356e8ab2894bbb9f7a21b37021c62d8 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Tue, 17 Feb 2026 02:05:53 +1100 Subject: [PATCH 03/10] basic POC sigwinch handler --- laser_prynter/pbar.py | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 2cdfc7e..ebd8508 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -60,20 +60,55 @@ def __init__(self, total: int, c1: RGB = DEFAULT_C1, c2: RGB = DEFAULT_C2): self.start_time = time.time() if self.t > self.w: - self.g = list(interp_xyz(c1, c2, self.t + 1)) + self.g = list(interp_xyz(self.c1, self.c2, self.t + 1)) else: - self.g = list(interp_xyz(c1, c2, self.w + 1)) + self.g = list(interp_xyz(self.c1, self.c2, self.w + 1)) self._iter_pbar = iter(self._pbar()) self.is_exiting = False signal.signal(signal.SIGINT, self.sigint_handler) + signal.signal(signal.SIGWINCH, self.sigwinch_handler) def sigint_handler(self, _signum: int, _frame: types.FrameType | None) -> None: self.is_exiting = True self._reset_terminal() sys.exit(0) + def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None: + self.w, self.h = _get_terminal_size() + _print_to_terminal( + '\x1b7' # save cursor position + f'\x1b[0;{self.h - 2}r' # set top & bottom regions (margins) - reserve 2 lines + f'\x1b[{self.h};1000H' # move to bottom line + '\r' + '\x1b[2K' # clear entire line + f'\x1b[{self.h - 1};1000H' # move to bottom line + '\r' + '\x1b[2K' # clear entire line + '\x1b8' # restore cursor position + '\x1b[2A' # move cursor up 2 lines + ) + + curr, progress = self.curr, self.progress + self.curr, self.progress = 0, 0 + if self.t > self.w: + self.g = list(interp_xyz(self.c1, self.c2, self.t + 1)) + else: + self.g = list(interp_xyz(self.c1, self.c2, self.w + 1)) + + self._iter_pbar = iter(self._pbar()) + + self._initial_bar() + self.progress = progress + for _ in range(curr): + try: + next(self) + except StopIteration: + break + + self._print_info() + @staticmethod def randgrad() -> tuple[RGB, RGB]: rgb1 = RGB(randint(0, 255), randint(0, 255), randint(0, 255)) From ed8bea5ecb7a67faaf8757b60e21a386085b99db Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 08:57:27 +1100 Subject: [PATCH 04/10] update --- laser_prynter/pbar.py | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 835496f..36059ca 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -55,38 +55,23 @@ def sigint_handler(self, _signum: int, _frame: types.FrameType | None) -> None: sys.exit(0) def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None: - self.w, self.h = _get_terminal_size() _print_to_terminal( - '\x1b7' # save cursor position - f'\x1b[0;{self.h - 2}r' # set top & bottom regions (margins) - reserve 2 lines - f'\x1b[{self.h};1000H' # move to bottom line - '\r' + f'\x1b[0;{self.h - 1}r' # set top & bottom regions (margins) - reserve 2 lines + f'\x1b[{self.h};0H' # move to bottom line '\x1b[2K' # clear entire line - f'\x1b[{self.h - 1};1000H' # move to bottom line - '\r' + f'\x1b[{self.h};0H' # move to bottom line '\x1b[2K' # clear entire line - '\x1b8' # restore cursor position - '\x1b[2A' # move cursor up 2 lines + '\x1b[10A' # move cursor up 2 lines ) + self.w, self.h = _get_terminal_size() + self.g = RGBGradient(start=self.g.start, end=self.g.end, steps=self.w) + self._initial_bar() - # curr, progress = self.curr, self.progress - # self.curr, self.progress = 0, 0 - # if self.t > self.w: - # self.g = list(interp_xyz(self.c1, self.c2, self.t + 1)) - # else: - # self.g = list(interp_xyz(self.c1, self.c2, self.w + 1)) - - # self._iter_pbar = iter(self._pbar()) - - # self._initial_bar() - # self.progress = progress - # for _ in range(curr): - # try: - # next(self) - # except StopIteration: - # break + i = self.i + self.i, self.x_pos = 0, 0 + self.update(i) - # self._print_info() + self._print_info() @staticmethod def randgrad() -> tuple[RGBColour, RGBColour]: From 60752d7982bc37a6cc20c8ff61fb35b6c391a092 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 12:34:54 +1100 Subject: [PATCH 05/10] update --- laser_prynter/pbar.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 36059ca..3884b2e 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -45,7 +45,7 @@ def __init__(self, total: int, c1: RGBColour = DEFAULT_C1, c2: RGBColour = DEFAU self.g = RGBGradient(start=c1, end=c2, steps=self.w) - self.is_exiting = False + self.is_exiting, self.is_winching = False, False signal.signal(signal.SIGINT, self.sigint_handler) signal.signal(signal.SIGWINCH, self.sigwinch_handler) @@ -55,6 +55,9 @@ def sigint_handler(self, _signum: int, _frame: types.FrameType | None) -> None: sys.exit(0) def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None: + self.is_winching = True + + def handle_resize(self) -> None: _print_to_terminal( f'\x1b[0;{self.h - 1}r' # set top & bottom regions (margins) - reserve 2 lines f'\x1b[{self.h};0H' # move to bottom line @@ -69,6 +72,7 @@ def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None i = self.i self.i, self.x_pos = 0, 0 + self.is_winching = False self.update(i) self._print_info() @@ -121,6 +125,8 @@ def _format_time(seconds: float) -> str: def _print_info(self) -> None: 'Print progress info in the line above the bar.' + if self.is_winching: + return elapsed = time.time() - self.start_time @@ -161,6 +167,11 @@ def _initial_bar(self) -> None: def update(self, n: int) -> None: 'update the progress bar by n steps' + if self.is_winching: + self.handle_resize() + self.is_winching = False + return + target_pos = self._pbar_terminal_x_at(self.i + n) for pos in range(self.x_pos, target_pos + 1): From 36b915bf59e15b39b23510299c405e15a30fe829 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 22:05:24 +1100 Subject: [PATCH 06/10] update --- laser_prynter/pbar.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 3884b2e..a8b8e20 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -169,8 +169,6 @@ def update(self, n: int) -> None: if self.is_winching: self.handle_resize() - self.is_winching = False - return target_pos = self._pbar_terminal_x_at(self.i + n) From 6e2f0e8a74f6fe24c0791074dea0595dc5476172 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 23:12:14 +1100 Subject: [PATCH 07/10] as stable as I can get sigwinch for now --- laser_prynter/pbar.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index a8b8e20..d7e3a40 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -58,24 +58,28 @@ def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None self.is_winching = True def handle_resize(self) -> None: + i, h = self.i, self.h + self.w, self.h = _get_terminal_size() + _print_to_terminal( - f'\x1b[0;{self.h - 1}r' # set top & bottom regions (margins) - reserve 2 lines - f'\x1b[{self.h};0H' # move to bottom line - '\x1b[2K' # clear entire line - f'\x1b[{self.h};0H' # move to bottom line - '\x1b[2K' # clear entire line - '\x1b[10A' # move cursor up 2 lines + f'\x1b[{h - 1};0H\x1b[2K' # move to info line & clear it + f'\x1b[{h};0H\x1b[2K' # move to bar line & clear it + f'\x1b[{self.h - 1};0H\x1b[2K' # move to info line & clear it + f'\x1b[{self.h};0H\x1b[2K' # move to bar line & clear it + f'\x1b[0;{self.h - 2}r' # set scrolling region, reserve 2 lines at bottom + f'\x1b[{self.h - 2};0H' # move cursor to last line of scrollable area ) - self.w, self.h = _get_terminal_size() + self.g = RGBGradient(start=self.g.start, end=self.g.end, steps=self.w) self._initial_bar() - i = self.i self.i, self.x_pos = 0, 0 self.is_winching = False - self.update(i) - self._print_info() + if i > 0: + self.update(i) + else: + self._print_info() @staticmethod def randgrad() -> tuple[RGBColour, RGBColour]: @@ -141,21 +145,19 @@ def _print_info(self) -> None: # Clear the line and print info above the progress bar _print_to_terminal( - '\x1b7' # save cursor position f'\x1b[{self.h - 1};0H' # move to line above bar '\x1b[2K' # clear entire line f'{item_info} | {time_info}' - '\x1b8' # restore cursor position + f'\x1b[{self.h - 2};0H' # mo`ve cursor to last line of scrollable area ) def _print_bar_char(self, s: str, colour: RGBColour, x_pos: int) -> None: _print_to_terminal( - '\x1b7' # save cursor position f'\x1b[{self.h};{x_pos}H' # move to bottom line f'\x1b[48;2;{colour.r};{colour.g};{colour.b}m' f'{s}' # the 'bar' characters '\x1b[0m' # reset color - '\x1b8' # restore cursor position + f'\x1b[{self.h - 2};0H' # move cursor to last line of scrollable area ) def _initial_bar(self) -> None: @@ -185,10 +187,7 @@ def __enter__(self) -> PBar: _print_to_terminal( '\x1b[?25l' # hide cursor '\n\n' # ensure space for info line and scrollbar - '\x1b7' # save cursor position f'\x1b[0;{self.h - 2}r' # set top & bottom regions (margins) - reserve 2 lines - '\x1b8' # restore cursor position - '\x1b[2A' # move cursor up 2 lines ) self._initial_bar() self._print_info() @@ -200,7 +199,7 @@ def _reset_terminal() -> None: _print_to_terminal( '\x1b[?25h' # show cursor f'\x1b[0;{h}r' # reset margins - f'\x1b[{h};1000H' # move to bottom line + f'\x1b[{h};0H' # move to bottom line '\n' ) From defe0c8c614af764106bbaf3ef2c97bae75c624f Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 23:23:10 +1100 Subject: [PATCH 08/10] check stderr first when getting terminal size --- laser_prynter/pbar.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index d7e3a40..460ff70 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -22,12 +22,17 @@ def _print_to_terminal(s: str) -> None: def _get_terminal_size() -> tuple[int, int]: - 'Get terminal size (width, height)' + ''' + Get terminal size (width, height) + - first try STDERR (as STDOUT is more likely to be redirected), + - then try STDOUT + - if both fail, return default (80, 24). + ''' try: - size = os.get_terminal_size(1) + size = os.get_terminal_size(2) except OSError: try: - size = os.get_terminal_size(2) + size = os.get_terminal_size(1) except OSError: return (80, 24) return (size.columns, size.lines) From f39ffbfe06ff4cddb7886cd88aaa904fb2098170 Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Wed, 18 Feb 2026 23:24:10 +1100 Subject: [PATCH 09/10] remove instance vars they don't really make a difference and it overcomplicates things --- laser_prynter/pbar.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/laser_prynter/pbar.py b/laser_prynter/pbar.py index 460ff70..587a50f 100644 --- a/laser_prynter/pbar.py +++ b/laser_prynter/pbar.py @@ -50,25 +50,23 @@ def __init__(self, total: int, c1: RGBColour = DEFAULT_C1, c2: RGBColour = DEFAU self.g = RGBGradient(start=c1, end=c2, steps=self.w) - self.is_exiting, self.is_winching = False, False signal.signal(signal.SIGINT, self.sigint_handler) signal.signal(signal.SIGWINCH, self.sigwinch_handler) def sigint_handler(self, _signum: int, _frame: types.FrameType | None) -> None: - self.is_exiting = True self._reset_terminal() sys.exit(0) def sigwinch_handler(self, _signum: int, _frame: types.FrameType | None) -> None: - self.is_winching = True + self.handle_resize() def handle_resize(self) -> None: i, h = self.i, self.h self.w, self.h = _get_terminal_size() _print_to_terminal( - f'\x1b[{h - 1};0H\x1b[2K' # move to info line & clear it - f'\x1b[{h};0H\x1b[2K' # move to bar line & clear it + f'\x1b[{h - 1};0H\x1b[2K' # move to old info line & clear it + f'\x1b[{h};0H\x1b[2K' # move to old bar line & clear it f'\x1b[{self.h - 1};0H\x1b[2K' # move to info line & clear it f'\x1b[{self.h};0H\x1b[2K' # move to bar line & clear it f'\x1b[0;{self.h - 2}r' # set scrolling region, reserve 2 lines at bottom @@ -79,7 +77,6 @@ def handle_resize(self) -> None: self._initial_bar() self.i, self.x_pos = 0, 0 - self.is_winching = False if i > 0: self.update(i) @@ -134,8 +131,6 @@ def _format_time(seconds: float) -> str: def _print_info(self) -> None: 'Print progress info in the line above the bar.' - if self.is_winching: - return elapsed = time.time() - self.start_time @@ -174,9 +169,6 @@ def _initial_bar(self) -> None: def update(self, n: int) -> None: 'update the progress bar by n steps' - if self.is_winching: - self.handle_resize() - target_pos = self._pbar_terminal_x_at(self.i + n) for pos in range(self.x_pos, target_pos + 1): @@ -191,8 +183,8 @@ def __enter__(self) -> PBar: self.start_time = time.time() _print_to_terminal( '\x1b[?25l' # hide cursor - '\n\n' # ensure space for info line and scrollbar - f'\x1b[0;{self.h - 2}r' # set top & bottom regions (margins) - reserve 2 lines + '\n\n' # ensure space for info line and progress bar + f'\x1b[0;{self.h - 2}r' # set top & bottom margins ) self._initial_bar() self._print_info() From dd8561c49995e72867e92d1dd1742aa91d35297b Mon Sep 17 00:00:00 2001 From: Tom McKeesick Date: Thu, 19 Feb 2026 00:04:15 +1100 Subject: [PATCH 10/10] minor version bump --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 82a3758..b3f6fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "laser-prynter" -version = "0.6.0" +version = "0.7.0" authors = [{ name = "tmck-code", email = "tmck01@gmail.com" }] description = "terminal/cli/python helpers for colour and pretty-printing" readme = "README.md" diff --git a/uv.lock b/uv.lock index 0f3afa7..0a9bb12 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.12" [[package]] name = "laser-prynter" -version = "0.6.0" +version = "0.7.0" source = { virtual = "." } dependencies = [ { name = "pygments" },