pacebar 0.1.0__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.
pacebar/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """PaceBar — a floating pacing timer for timeboxed stages."""
2
+
3
+ __version__ = "0.1.0"
pacebar/__main__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Entry point: `python -m pacebar` / the `pacebar` script / the exe."""
2
+
3
+ import sys
4
+
5
+ from PySide6.QtWidgets import QApplication
6
+
7
+ from .app import App
8
+ from .constants import APP_DISPLAY_NAME
9
+
10
+
11
+ def main() -> int:
12
+ qt_application = QApplication(sys.argv)
13
+ qt_application.setApplicationName(APP_DISPLAY_NAME)
14
+ app = App()
15
+ app.show()
16
+ return qt_application.exec()
17
+
18
+
19
+ if __name__ == "__main__":
20
+ sys.exit(main())
pacebar/app.py ADDED
@@ -0,0 +1,24 @@
1
+ """Application controller: wires the editor to a run, persists the run on start."""
2
+
3
+ from PySide6.QtWidgets import QApplication
4
+
5
+ from .editor import EditorWindow
6
+ from .overlay import RunController
7
+ from .persistence import save_sections
8
+
9
+
10
+ class App:
11
+ def __init__(self):
12
+ self.editor = EditorWindow()
13
+ self.editor.start_requested.connect(self._start_run)
14
+ self.run: RunController | None = None
15
+
16
+ def show(self):
17
+ self.editor.show()
18
+
19
+ def _start_run(self, sections, lateness_seconds, yellow_percent, yellow_seconds):
20
+ # Persist the schedule (order + minutes + names only) as the run begins.
21
+ save_sections(sections)
22
+ self.editor.hide()
23
+ self.run = RunController(sections, lateness_seconds, yellow_percent, yellow_seconds)
24
+ self.run.finished.connect(QApplication.instance().quit)
pacebar/constants.py ADDED
@@ -0,0 +1,29 @@
1
+ """Constants shared across modules.
2
+
3
+ Purely cosmetic, single-use dimensions live next to the code that uses them;
4
+ this file holds values that are shared or worth tuning in one place.
5
+ """
6
+
7
+ # Identity and storage
8
+ APP_DISPLAY_NAME = "PaceBar"
9
+ LAST_RUN_FILENAME = "pacebar_last_run.json"
10
+
11
+ # Section / editor limits and defaults
12
+ MIN_SECTION_MINUTES = 1
13
+ MAX_SECTION_MINUTES = 999
14
+ MAX_LATENESS_MINUTES = 999
15
+ MAX_SECTION_NAME_LENGTH = 30
16
+ MAX_YELLOW_PERCENT = 100
17
+ MAX_YELLOW_SECONDS = 3600
18
+ DEFAULT_YELLOW_PERCENT = 10
19
+ DEFAULT_YELLOW_SECONDS = 30
20
+
21
+ # Overlay rendering
22
+ RENDER_INTERVAL_MS = 100 # ~10 Hz; the smallest shown unit is one second
23
+ OVERLAY_FONT_POINT_INCREASE = 2
24
+ OVERLAY_HEIGHT_FACTOR = 1.6
25
+
26
+ # Pastel status colors for the strip and minimized square
27
+ PASTEL_GREEN = "#cfe8cf"
28
+ PASTEL_YELLOW = "#f6efb4"
29
+ PASTEL_RED = "#f2c4c4"
@@ -0,0 +1,5 @@
1
+ """The setup window where the user defines the meeting's sections."""
2
+
3
+ from .window import EditorWindow
4
+
5
+ __all__ = ["EditorWindow"]
@@ -0,0 +1,91 @@
1
+ """Input widgets for a section row: the minutes spin box and the name field."""
2
+
3
+ from PySide6.QtCore import QEvent, Qt, QTimer, Signal
4
+ from PySide6.QtWidgets import QLineEdit, QSpinBox
5
+
6
+ from ..constants import (
7
+ MAX_SECTION_MINUTES,
8
+ MAX_SECTION_NAME_LENGTH,
9
+ MIN_SECTION_MINUTES,
10
+ PASTEL_RED,
11
+ )
12
+
13
+ _TAB_KEYS = (Qt.Key_Tab, Qt.Key_Backtab)
14
+ _ENTER_KEYS = (Qt.Key_Return, Qt.Key_Enter)
15
+ _MINUTES_FIELD_WIDTH = 95
16
+ _NAME_OVERFLOW_BLINK_MS = 120
17
+
18
+
19
+ class MinutesSpinBox(QSpinBox):
20
+ """Whole-minutes spin box that keeps Tab and Enter inside the current row."""
21
+
22
+ tab_pressed = Signal()
23
+ enter_pressed = Signal()
24
+
25
+ def __init__(self, value: int = MIN_SECTION_MINUTES):
26
+ super().__init__()
27
+ self.setRange(MIN_SECTION_MINUTES, MAX_SECTION_MINUTES)
28
+ self.setValue(max(MIN_SECTION_MINUTES, value))
29
+ self.setSuffix(" min")
30
+ self.setFixedWidth(_MINUTES_FIELD_WIDTH)
31
+
32
+ def event(self, event):
33
+ # Tab is consumed by the focus framework before keyPressEvent, so we
34
+ # intercept it here in event() instead.
35
+ if event.type() == QEvent.KeyPress:
36
+ if event.key() in _TAB_KEYS:
37
+ self.tab_pressed.emit()
38
+ return True
39
+ if event.key() in _ENTER_KEYS:
40
+ self.enter_pressed.emit()
41
+ return True
42
+ return super().event(event)
43
+
44
+
45
+ class NameLineEdit(QLineEdit):
46
+ """Section name field: caps at MAX_SECTION_NAME_LENGTH and blinks when full."""
47
+
48
+ tab_pressed = Signal()
49
+ enter_pressed = Signal()
50
+ up_pressed = Signal()
51
+ down_pressed = Signal()
52
+
53
+ def __init__(self, text: str = ""):
54
+ super().__init__(text)
55
+ self.setMaxLength(MAX_SECTION_NAME_LENGTH)
56
+ self.setPlaceholderText("Section name")
57
+ self._default_style = self.styleSheet()
58
+
59
+ def event(self, event):
60
+ if event.type() == QEvent.KeyPress:
61
+ key = event.key()
62
+ if key in _TAB_KEYS:
63
+ self.tab_pressed.emit()
64
+ return True
65
+ if key in _ENTER_KEYS:
66
+ self.enter_pressed.emit()
67
+ return True
68
+ # Up/Down jump between rows (the spin box keeps arrows for its value).
69
+ if key == Qt.Key_Up:
70
+ self.up_pressed.emit()
71
+ return True
72
+ if key == Qt.Key_Down:
73
+ self.down_pressed.emit()
74
+ return True
75
+ if self._is_overflow_keystroke(event):
76
+ self._blink()
77
+ return True
78
+ return super().event(event)
79
+
80
+ def _is_overflow_keystroke(self, event) -> bool:
81
+ text = event.text()
82
+ return bool(
83
+ text
84
+ and text.isprintable()
85
+ and len(self.text()) >= MAX_SECTION_NAME_LENGTH
86
+ and not self.hasSelectedText()
87
+ )
88
+
89
+ def _blink(self):
90
+ self.setStyleSheet(f"background:{PASTEL_RED};")
91
+ QTimer.singleShot(_NAME_OVERFLOW_BLINK_MS, lambda: self.setStyleSheet(self._default_style))
pacebar/editor/row.py ADDED
@@ -0,0 +1,83 @@
1
+ """A single editable section row."""
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtWidgets import QHBoxLayout, QPushButton, QToolButton, QVBoxLayout, QWidget
5
+
6
+ from ..timing import Section
7
+ from .fields import MinutesSpinBox, NameLineEdit
8
+
9
+ _MOVE_BUTTON_WIDTH = 26
10
+ _MOVE_BUTTON_HEIGHT = 13
11
+ _ROW_BUTTON_WIDTH = 30
12
+
13
+
14
+ class RowWidget(QWidget):
15
+ """One section row: minutes, name, reorder, add and delete controls."""
16
+
17
+ add_after_requested = Signal(object)
18
+ delete_requested = Signal(object)
19
+ move_up_requested = Signal(object)
20
+ move_down_requested = Signal(object)
21
+ focus_up_requested = Signal(object)
22
+ focus_down_requested = Signal(object)
23
+
24
+ def __init__(self, minutes: int = 1, name: str = ""):
25
+ super().__init__()
26
+ layout = QHBoxLayout(self)
27
+ layout.setContentsMargins(0, 0, 0, 0)
28
+ layout.setSpacing(6)
29
+
30
+ self.minutes = MinutesSpinBox(minutes)
31
+ self.name = NameLineEdit(name)
32
+
33
+ self.move_up_button = QToolButton()
34
+ self.move_up_button.setText("▲")
35
+ self.move_down_button = QToolButton()
36
+ self.move_down_button.setText("▼")
37
+ move_box = self._build_move_box()
38
+
39
+ self.add_button = QPushButton("+")
40
+ self.delete_button = QPushButton("✕")
41
+ for button in (self.add_button, self.delete_button):
42
+ button.setFixedWidth(_ROW_BUTTON_WIDTH)
43
+ button.setFocusPolicy(Qt.NoFocus)
44
+
45
+ layout.addWidget(self.minutes)
46
+ layout.addWidget(self.name, 1)
47
+ layout.addWidget(move_box)
48
+ layout.addWidget(self.add_button)
49
+ layout.addWidget(self.delete_button)
50
+
51
+ self._connect_signals()
52
+
53
+ def _build_move_box(self) -> QWidget:
54
+ """A visually-joined up/down control."""
55
+ box = QWidget()
56
+ box_layout = QVBoxLayout(box)
57
+ box_layout.setContentsMargins(0, 0, 0, 0)
58
+ box_layout.setSpacing(0)
59
+ for button in (self.move_up_button, self.move_down_button):
60
+ button.setAutoRaise(True)
61
+ button.setFocusPolicy(Qt.NoFocus)
62
+ button.setFixedSize(_MOVE_BUTTON_WIDTH, _MOVE_BUTTON_HEIGHT)
63
+ box_layout.addWidget(button)
64
+ return box
65
+
66
+ def _connect_signals(self):
67
+ # Tab / Shift+Tab cycle only between the two fields of this row.
68
+ self.minutes.tab_pressed.connect(self.name.setFocus)
69
+ self.name.tab_pressed.connect(self.minutes.setFocus)
70
+ # Enter in either field appends a new row.
71
+ self.minutes.enter_pressed.connect(lambda: self.add_after_requested.emit(self))
72
+ self.name.enter_pressed.connect(lambda: self.add_after_requested.emit(self))
73
+ # Up/Down in the name field move between existing rows.
74
+ self.name.up_pressed.connect(lambda: self.focus_up_requested.emit(self))
75
+ self.name.down_pressed.connect(lambda: self.focus_down_requested.emit(self))
76
+
77
+ self.move_up_button.clicked.connect(lambda: self.move_up_requested.emit(self))
78
+ self.move_down_button.clicked.connect(lambda: self.move_down_requested.emit(self))
79
+ self.add_button.clicked.connect(lambda: self.add_after_requested.emit(self))
80
+ self.delete_button.clicked.connect(lambda: self.delete_requested.emit(self))
81
+
82
+ def section(self) -> Section:
83
+ return Section(self.minutes.value(), self.name.text().strip())
@@ -0,0 +1,178 @@
1
+ """The setup window: a toolbar plus one editable row per section."""
2
+
3
+ from PySide6.QtCore import Signal
4
+ from PySide6.QtWidgets import (
5
+ QFrame,
6
+ QHBoxLayout,
7
+ QLabel,
8
+ QPushButton,
9
+ QScrollArea,
10
+ QSpinBox,
11
+ QVBoxLayout,
12
+ QWidget,
13
+ )
14
+
15
+ from ..constants import (
16
+ APP_DISPLAY_NAME,
17
+ DEFAULT_YELLOW_PERCENT,
18
+ DEFAULT_YELLOW_SECONDS,
19
+ MAX_LATENESS_MINUTES,
20
+ MAX_YELLOW_PERCENT,
21
+ MAX_YELLOW_SECONDS,
22
+ )
23
+ from ..persistence import load_sections
24
+ from .row import RowWidget
25
+
26
+ _WINDOW_DEFAULT_SIZE = (560, 440)
27
+ _SECONDS_PER_MINUTE = 60
28
+
29
+
30
+ class EditorWindow(QWidget):
31
+ """Top-level setup window."""
32
+
33
+ # sections, lateness_seconds, yellow_percent, yellow_seconds
34
+ start_requested = Signal(object, int, int, int)
35
+
36
+ def __init__(self):
37
+ super().__init__()
38
+ self.setWindowTitle(APP_DISPLAY_NAME)
39
+ self.rows: list[RowWidget] = []
40
+
41
+ root_layout = QVBoxLayout(self)
42
+ root_layout.addLayout(self._build_toolbar())
43
+ root_layout.addWidget(self._build_rows_area(), 1)
44
+
45
+ self._load_initial_rows()
46
+ self.resize(*_WINDOW_DEFAULT_SIZE)
47
+
48
+ # --- construction ---------------------------------------------------
49
+
50
+ def _build_toolbar(self) -> QHBoxLayout:
51
+ toolbar = QHBoxLayout()
52
+
53
+ self.start_button = QPushButton("▶ Start")
54
+ self.start_button.clicked.connect(self._on_start)
55
+ self.lateness = QSpinBox()
56
+ self.lateness.setRange(0, MAX_LATENESS_MINUTES)
57
+ self.lateness.setSuffix(" min late")
58
+ self.reset_button = QPushButton("⟳ Reset")
59
+ self.reset_button.clicked.connect(self.reset)
60
+
61
+ toolbar.addWidget(self.start_button)
62
+ toolbar.addWidget(self.lateness)
63
+ toolbar.addWidget(self.reset_button)
64
+ toolbar.addStretch(1)
65
+
66
+ toolbar.addWidget(QLabel("Yellow at"))
67
+ self.yellow_percent = QSpinBox()
68
+ self.yellow_percent.setRange(0, MAX_YELLOW_PERCENT)
69
+ self.yellow_percent.setValue(DEFAULT_YELLOW_PERCENT)
70
+ self.yellow_percent.setSuffix(" %")
71
+ self.yellow_seconds = QSpinBox()
72
+ self.yellow_seconds.setRange(0, MAX_YELLOW_SECONDS)
73
+ self.yellow_seconds.setValue(DEFAULT_YELLOW_SECONDS)
74
+ self.yellow_seconds.setSuffix(" s")
75
+ toolbar.addWidget(self.yellow_percent)
76
+ toolbar.addWidget(QLabel("or"))
77
+ toolbar.addWidget(self.yellow_seconds)
78
+ return toolbar
79
+
80
+ def _build_rows_area(self) -> QScrollArea:
81
+ self.rows_container = QWidget()
82
+ self.rows_layout = QVBoxLayout(self.rows_container)
83
+ self.rows_layout.setContentsMargins(0, 0, 0, 0)
84
+ self.rows_layout.setSpacing(4)
85
+ self.rows_layout.addStretch(1)
86
+
87
+ scroll_area = QScrollArea()
88
+ scroll_area.setWidgetResizable(True)
89
+ scroll_area.setWidget(self.rows_container)
90
+ scroll_area.setFrameShape(QFrame.NoFrame)
91
+ return scroll_area
92
+
93
+ # --- row management -------------------------------------------------
94
+
95
+ def _make_row(self, minutes: int, name: str) -> RowWidget:
96
+ row = RowWidget(minutes, name)
97
+ row.add_after_requested.connect(self._add_after)
98
+ row.delete_requested.connect(self._delete)
99
+ row.move_up_requested.connect(self._move_up)
100
+ row.move_down_requested.connect(self._move_down)
101
+ row.focus_up_requested.connect(self._focus_up)
102
+ row.focus_down_requested.connect(self._focus_down)
103
+ return row
104
+
105
+ def _insert_row(self, index: int, minutes: int = 1, name: str = "") -> RowWidget:
106
+ row = self._make_row(minutes, name)
107
+ self.rows.insert(index, row)
108
+ self.rows_layout.insertWidget(index, row) # trailing stretch stays last
109
+ return row
110
+
111
+ def _add_after(self, row: RowWidget):
112
+ index = self.rows.index(row) + 1
113
+ new_row = self._insert_row(index)
114
+ new_row.minutes.setFocus()
115
+
116
+ def _delete(self, row: RowWidget):
117
+ self.rows.remove(row)
118
+ row.setParent(None)
119
+ row.deleteLater()
120
+ if not self.rows:
121
+ self._insert_row(0)
122
+
123
+ def _move_up(self, row: RowWidget):
124
+ index = self.rows.index(row)
125
+ if index > 0:
126
+ self._swap(index, index - 1)
127
+
128
+ def _move_down(self, row: RowWidget):
129
+ index = self.rows.index(row)
130
+ if index < len(self.rows) - 1:
131
+ self._swap(index, index + 1)
132
+
133
+ def _swap(self, first: int, second: int):
134
+ self.rows[first], self.rows[second] = self.rows[second], self.rows[first]
135
+ for row in self.rows:
136
+ self.rows_layout.removeWidget(row)
137
+ for position, row in enumerate(self.rows):
138
+ self.rows_layout.insertWidget(position, row)
139
+
140
+ def _focus_up(self, row: RowWidget):
141
+ index = self.rows.index(row)
142
+ if index > 0:
143
+ self.rows[index - 1].name.setFocus()
144
+
145
+ def _focus_down(self, row: RowWidget):
146
+ index = self.rows.index(row)
147
+ if index < len(self.rows) - 1:
148
+ self.rows[index + 1].name.setFocus()
149
+
150
+ def _load_initial_rows(self):
151
+ saved_sections = load_sections()
152
+ if saved_sections:
153
+ for section in saved_sections:
154
+ self._insert_row(len(self.rows), section.minutes, section.name)
155
+ else:
156
+ self._insert_row(0)
157
+
158
+ # --- actions --------------------------------------------------------
159
+
160
+ def reset(self):
161
+ for row in list(self.rows):
162
+ row.setParent(None)
163
+ row.deleteLater()
164
+ self.rows.clear()
165
+ self._insert_row(0)
166
+
167
+ def _on_start(self):
168
+ sections = [row.section() for row in self.rows]
169
+ sections = [section for section in sections if section.name]
170
+ if not sections:
171
+ return
172
+ lateness_seconds = self.lateness.value() * _SECONDS_PER_MINUTE
173
+ self.start_requested.emit(
174
+ sections,
175
+ lateness_seconds,
176
+ self.yellow_percent.value(),
177
+ self.yellow_seconds.value(),
178
+ )
pacebar/formatting.py ADDED
@@ -0,0 +1,14 @@
1
+ """Display formatting helpers."""
2
+
3
+
4
+ def format_duration(total_seconds: float, show_hours: bool) -> str:
5
+ """Format seconds as [-]HH:MM:SS, hiding the hours group when not needed."""
6
+ is_negative = total_seconds < 0
7
+ whole_seconds = int(abs(total_seconds))
8
+ hours, remainder = divmod(whole_seconds, 3600)
9
+ minutes, seconds = divmod(remainder, 60)
10
+ if show_hours:
11
+ body = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
12
+ else:
13
+ body = f"{minutes:02d}:{seconds:02d}"
14
+ return ("-" if is_negative else "") + body
@@ -0,0 +1,5 @@
1
+ """The running overlay: the floating strip, the minimized square and hotkeys."""
2
+
3
+ from .controller import RunController
4
+
5
+ __all__ = ["RunController"]
pacebar/overlay/bar.py ADDED
@@ -0,0 +1,80 @@
1
+ """The flat top strip shown while a meeting runs."""
2
+
3
+ from PySide6.QtCore import Qt, Signal
4
+ from PySide6.QtGui import QFont
5
+ from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QWidget
6
+
7
+ _FRAMELESS_TOPMOST = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
8
+
9
+
10
+ class OverlayBar(QWidget):
11
+ """The flat top strip. Every variable-width part is pinned so width is stable."""
12
+
13
+ back_clicked = Signal()
14
+ next_clicked = Signal()
15
+ minimize_clicked = Signal()
16
+ cancel_clicked = Signal()
17
+
18
+ def __init__(
19
+ self,
20
+ font: QFont,
21
+ height: int,
22
+ timer_width: int,
23
+ name_width: int,
24
+ next_width: int,
25
+ ):
26
+ super().__init__(None, _FRAMELESS_TOPMOST)
27
+ self.setAttribute(Qt.WA_StyledBackground, True)
28
+ self.setFixedHeight(height)
29
+
30
+ layout = QHBoxLayout(self)
31
+ layout.setContentsMargins(8, 0, 8, 0)
32
+ layout.setSpacing(10)
33
+
34
+ self.back_button = self._make_flat_button("◀", font)
35
+ self.timer_label = QLabel()
36
+ self.timer_label.setFont(font)
37
+ self.timer_label.setFixedWidth(timer_width)
38
+ self.current_label = QLabel()
39
+ self.current_label.setFont(font)
40
+ self.current_label.setFixedWidth(name_width)
41
+ self.next_button = self._make_flat_button("", font)
42
+ self.next_button.setFixedWidth(next_width)
43
+ self.minimize_button = self._make_flat_button("▢", font)
44
+ self.cancel_button = self._make_flat_button("✕", font)
45
+
46
+ layout.addWidget(self.back_button)
47
+ layout.addWidget(self.timer_label)
48
+ layout.addWidget(self.current_label)
49
+ layout.addWidget(self.next_button)
50
+ layout.addWidget(self.minimize_button)
51
+ layout.addWidget(self.cancel_button)
52
+
53
+ self.back_button.clicked.connect(self.back_clicked)
54
+ self.next_button.clicked.connect(self.next_clicked)
55
+ self.minimize_button.clicked.connect(self.minimize_clicked)
56
+ self.cancel_button.clicked.connect(self.cancel_clicked)
57
+
58
+ def _make_flat_button(self, text: str, font: QFont) -> QPushButton:
59
+ button = QPushButton(text)
60
+ button.setFont(font)
61
+ button.setFlat(True)
62
+ button.setCursor(Qt.PointingHandCursor)
63
+ button.setFocusPolicy(Qt.NoFocus)
64
+ button.setStyleSheet(
65
+ "QPushButton{border:none;background:transparent;padding:0 4px;text-align:left;}"
66
+ "QPushButton:hover{background:rgba(0,0,0,0.08);}"
67
+ )
68
+ return button
69
+
70
+ def set_color(self, color: str):
71
+ self.setStyleSheet(f"OverlayBar{{background:{color};}}")
72
+
73
+ def set_timer_text(self, text: str):
74
+ self.timer_label.setText(text)
75
+
76
+ def set_current(self, name: str):
77
+ self.current_label.setText(name)
78
+
79
+ def set_next(self, label: str):
80
+ self.next_button.setText(label)
@@ -0,0 +1,154 @@
1
+ """Run controller: owns the timer model, both windows, render loop and hotkeys."""
2
+
3
+ from PySide6.QtCore import QObject, QPoint, QTimer, Signal
4
+ from PySide6.QtGui import QCursor, QFontMetrics, QGuiApplication
5
+ from PySide6.QtWidgets import QApplication
6
+
7
+ from ..constants import (
8
+ OVERLAY_FONT_POINT_INCREASE,
9
+ OVERLAY_HEIGHT_FACTOR,
10
+ PASTEL_GREEN,
11
+ PASTEL_RED,
12
+ PASTEL_YELLOW,
13
+ RENDER_INTERVAL_MS,
14
+ )
15
+ from ..formatting import format_duration
16
+ from ..timing import GREEN, RED, YELLOW, TimerModel
17
+ from .bar import OverlayBar
18
+ from .hotkeys import HotkeyBridge, start_global_hotkeys
19
+ from .square import MiniSquare
20
+
21
+ _STATUS_COLORS = {GREEN: PASTEL_GREEN, YELLOW: PASTEL_YELLOW, RED: PASTEL_RED}
22
+
23
+ _SECONDS_PER_HOUR = 3600
24
+ _TIMER_WIDTH_PADDING = 6
25
+ _NAME_WIDTH_PADDING = 6
26
+ _NEXT_WIDTH_PADDING = 10
27
+ _SQUARE_TOP_MARGIN = 10
28
+
29
+
30
+ class RunController(QObject):
31
+ """Owns the timer model, both windows, the render loop and global hotkeys."""
32
+
33
+ finished = Signal()
34
+
35
+ def __init__(self, sections, lateness_seconds, yellow_percent, yellow_seconds):
36
+ super().__init__()
37
+ self.model = TimerModel(sections, lateness_seconds)
38
+ self.yellow_percent = yellow_percent
39
+ self.yellow_seconds = yellow_seconds
40
+
41
+ font = QApplication.font()
42
+ if font.pointSize() > 0:
43
+ font.setPointSize(font.pointSize() + OVERLAY_FONT_POINT_INCREASE)
44
+ metrics = QFontMetrics(font)
45
+ bar_height = int(metrics.height() * OVERLAY_HEIGHT_FACTOR)
46
+
47
+ self.show_hours = max(s.seconds for s in sections) >= _SECONDS_PER_HOUR
48
+ timer_width, name_width, next_width = self._measure_widths(metrics, sections)
49
+
50
+ self.bar = OverlayBar(font, bar_height, timer_width, name_width, next_width)
51
+ self.bar.adjustSize()
52
+ self.bar.setFixedWidth(self.bar.width())
53
+ self.square = MiniSquare(bar_height)
54
+ self._square_shown = False
55
+ self._square_home = QPoint()
56
+
57
+ self._connect_windows()
58
+
59
+ self._render_timer = QTimer(self)
60
+ self._render_timer.timeout.connect(self.tick)
61
+ self._render_timer.start(RENDER_INTERVAL_MS)
62
+
63
+ self._place_bar()
64
+ self.bar.show()
65
+ self.tick()
66
+
67
+ self._hotkey_bridge = HotkeyBridge()
68
+ self._hotkey_bridge.next_requested.connect(self.on_next_button)
69
+ self._hotkey_bridge.back_requested.connect(self.on_back)
70
+ self._hotkey_listener = start_global_hotkeys(self._hotkey_bridge)
71
+
72
+ # --- setup ----------------------------------------------------------
73
+
74
+ def _measure_widths(self, metrics: QFontMetrics, sections):
75
+ """Size every variable-width part to its worst case for this run."""
76
+ worst_seconds = max(s.seconds for s in sections)
77
+ worst_timer_text = "-" + format_duration(worst_seconds, self.show_hours)
78
+ timer_width = metrics.horizontalAdvance(worst_timer_text) + _TIMER_WIDTH_PADDING
79
+
80
+ longest_name = max((s.name for s in sections), key=len, default="")
81
+ name_width = metrics.horizontalAdvance(longest_name) + _NAME_WIDTH_PADDING
82
+
83
+ next_labels = ["→ " + s.name for s in sections] + ["→ End"]
84
+ widest_next = max(metrics.horizontalAdvance(label) for label in next_labels)
85
+ next_width = widest_next + _NEXT_WIDTH_PADDING
86
+ return timer_width, name_width, next_width
87
+
88
+ def _connect_windows(self):
89
+ self.bar.back_clicked.connect(self.on_back)
90
+ self.bar.next_clicked.connect(self.on_next_button)
91
+ self.bar.minimize_clicked.connect(self.minimize)
92
+ self.bar.cancel_clicked.connect(self.cancel)
93
+ self.square.clicked.connect(self.restore)
94
+
95
+ # --- placement ------------------------------------------------------
96
+
97
+ def _place_bar(self):
98
+ screen = QGuiApplication.screenAt(QCursor.pos()) or QGuiApplication.primaryScreen()
99
+ geometry = screen.availableGeometry()
100
+ left = geometry.x() + (geometry.width() - self.bar.width()) // 2
101
+ self.bar.move(left, geometry.y())
102
+ square_left = geometry.x() + (geometry.width() - self.square.width()) // 2
103
+ self._square_home = QPoint(square_left, geometry.y() + _SQUARE_TOP_MARGIN)
104
+
105
+ # --- render ---------------------------------------------------------
106
+
107
+ def tick(self):
108
+ remaining = self.model.remaining()
109
+ self.bar.set_timer_text(format_duration(remaining, self.show_hours))
110
+ self.bar.set_current(self.model.current.name)
111
+ upcoming = self.model.next_section
112
+ self.bar.set_next("→ " + (upcoming.name if upcoming else "End"))
113
+ status = self.model.color(self.yellow_percent, self.yellow_seconds)
114
+ color = _STATUS_COLORS[status]
115
+ self.bar.set_color(color)
116
+ self.square.set_color(color)
117
+
118
+ # --- actions --------------------------------------------------------
119
+
120
+ def on_next_button(self):
121
+ # On the last section the button reads "End" and quits the program.
122
+ if self.model.is_last:
123
+ self.cancel()
124
+ else:
125
+ self.model.advance()
126
+ self.tick()
127
+
128
+ def on_back(self):
129
+ if self.model.go_back():
130
+ self.tick()
131
+
132
+ def minimize(self):
133
+ if not self._square_shown:
134
+ self.square.move(self._square_home)
135
+ self._square_shown = True
136
+ self.bar.hide()
137
+ self.square.show()
138
+
139
+ def restore(self):
140
+ self.square.hide()
141
+ self._place_bar()
142
+ self.bar.show()
143
+
144
+ def cancel(self):
145
+ self._render_timer.stop()
146
+ self._stop_hotkeys()
147
+ self.bar.close()
148
+ self.square.close()
149
+ self.finished.emit()
150
+
151
+ def _stop_hotkeys(self):
152
+ if self._hotkey_listener is not None:
153
+ self._hotkey_listener.stop()
154
+ self._hotkey_listener = None
@@ -0,0 +1,112 @@
1
+ """Global hotkeys.
2
+
3
+ On Windows we use the native ``RegisterHotKey`` API plugged into Qt's own
4
+ message loop via a native event filter. That is far more robust in a frozen
5
+ ``--windowed`` exe than a background low-level keyboard hook (which silently
6
+ receives no events there). On other platforms we fall back to pynput.
7
+ """
8
+
9
+ import sys
10
+
11
+ from PySide6.QtCore import QAbstractNativeEventFilter, QCoreApplication, QObject, Signal
12
+
13
+
14
+ class HotkeyBridge(QObject):
15
+ """Carries hotkey events as Qt signals delivered on the main thread."""
16
+
17
+ next_requested = Signal()
18
+ back_requested = Signal()
19
+
20
+
21
+ # Win32 RegisterHotKey modifiers / message / virtual-key codes.
22
+ _MOD_ALT = 0x0001
23
+ _MOD_CONTROL = 0x0002
24
+ _MOD_NOREPEAT = 0x4000
25
+ _WM_HOTKEY = 0x0312
26
+ _VK_A = 0x41
27
+ _VK_D = 0x44
28
+ _HOTKEY_NEXT = 1
29
+ _HOTKEY_BACK = 2
30
+
31
+
32
+ class _WindowsHotkeys(QAbstractNativeEventFilter):
33
+ """Ctrl+Alt+D / Ctrl+Alt+A via native RegisterHotKey + Qt's message pump."""
34
+
35
+ def __init__(self, bridge: HotkeyBridge):
36
+ super().__init__()
37
+ import ctypes
38
+ from ctypes import wintypes
39
+
40
+ self._wintypes = wintypes
41
+ self._user32 = ctypes.windll.user32
42
+ self._user32.RegisterHotKey.argtypes = [
43
+ wintypes.HWND,
44
+ ctypes.c_int,
45
+ wintypes.UINT,
46
+ wintypes.UINT,
47
+ ]
48
+ self._user32.RegisterHotKey.restype = wintypes.BOOL
49
+ self._user32.UnregisterHotKey.argtypes = [wintypes.HWND, ctypes.c_int]
50
+ self._user32.UnregisterHotKey.restype = wintypes.BOOL
51
+
52
+ self._bridge = bridge
53
+ self._ids: list[int] = []
54
+ modifiers = _MOD_CONTROL | _MOD_ALT | _MOD_NOREPEAT
55
+ for hotkey_id, virtual_key in ((_HOTKEY_NEXT, _VK_D), (_HOTKEY_BACK, _VK_A)):
56
+ if self._user32.RegisterHotKey(None, hotkey_id, modifiers, virtual_key):
57
+ self._ids.append(hotkey_id)
58
+ QCoreApplication.instance().installNativeEventFilter(self)
59
+
60
+ def nativeEventFilter(self, event_type, message):
61
+ if event_type == b"windows_generic_MSG":
62
+ msg = self._wintypes.MSG.from_address(int(message))
63
+ if msg.message == _WM_HOTKEY:
64
+ if msg.wParam == _HOTKEY_NEXT:
65
+ self._bridge.next_requested.emit()
66
+ elif msg.wParam == _HOTKEY_BACK:
67
+ self._bridge.back_requested.emit()
68
+ return False, 0
69
+
70
+ def stop(self):
71
+ app = QCoreApplication.instance()
72
+ if app is not None:
73
+ app.removeNativeEventFilter(self)
74
+ for hotkey_id in self._ids:
75
+ self._user32.UnregisterHotKey(None, hotkey_id)
76
+ self._ids = []
77
+
78
+
79
+ def start_global_hotkeys(bridge: HotkeyBridge):
80
+ """Start global hotkeys. Returns a handle with .stop(), or None if unavailable."""
81
+ if sys.platform == "win32":
82
+ try:
83
+ return _WindowsHotkeys(bridge)
84
+ except Exception:
85
+ return None
86
+ return _start_pynput(bridge)
87
+
88
+
89
+ def _start_pynput(bridge: HotkeyBridge):
90
+ """Fallback for non-Windows (X11 etc.). May be blocked under Wayland."""
91
+ try:
92
+ from pynput import keyboard
93
+ except Exception:
94
+ return None
95
+
96
+ def on_next():
97
+ bridge.next_requested.emit()
98
+
99
+ def on_back():
100
+ bridge.back_requested.emit()
101
+
102
+ try:
103
+ listener = keyboard.GlobalHotKeys(
104
+ {
105
+ "<ctrl>+<alt>+d": on_next,
106
+ "<ctrl>+<alt>+a": on_back,
107
+ }
108
+ )
109
+ listener.start()
110
+ return listener
111
+ except Exception:
112
+ return None
@@ -0,0 +1,42 @@
1
+ """The minimized square: a draggable, color-only status indicator."""
2
+
3
+ from PySide6.QtCore import QPoint, Qt, Signal
4
+ from PySide6.QtWidgets import QWidget
5
+
6
+ _FRAMELESS_TOPMOST = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint | Qt.Tool
7
+ _DRAG_THRESHOLD_PX = 4
8
+
9
+
10
+ class MiniSquare(QWidget):
11
+ """Small draggable square that signals status by color; a click restores."""
12
+
13
+ clicked = Signal()
14
+
15
+ def __init__(self, size: int):
16
+ super().__init__(None, _FRAMELESS_TOPMOST)
17
+ self.setAttribute(Qt.WA_StyledBackground, True)
18
+ self.setFixedSize(size, size)
19
+ self._press_position = None
20
+ self._window_origin = QPoint()
21
+ self._dragged = False
22
+
23
+ def set_color(self, color: str):
24
+ self.setStyleSheet(f"background:{color};")
25
+
26
+ def mousePressEvent(self, event):
27
+ self._press_position = event.globalPosition().toPoint()
28
+ self._window_origin = self.pos()
29
+ self._dragged = False
30
+
31
+ def mouseMoveEvent(self, event):
32
+ if self._press_position is None:
33
+ return
34
+ offset = event.globalPosition().toPoint() - self._press_position
35
+ if offset.manhattanLength() > _DRAG_THRESHOLD_PX:
36
+ self._dragged = True
37
+ self.move(self._window_origin + offset)
38
+
39
+ def mouseReleaseEvent(self, event):
40
+ if self._press_position is not None and not self._dragged:
41
+ self.clicked.emit()
42
+ self._press_position = None
pacebar/paths.py ADDED
@@ -0,0 +1,20 @@
1
+ """Filesystem locations for persisted state."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from .constants import LAST_RUN_FILENAME
7
+
8
+
9
+ def state_directory() -> Path:
10
+ """Folder of the running exe, so each copy keeps its own typical schedule.
11
+
12
+ When running from source there is no exe, so fall back to the current dir.
13
+ """
14
+ if getattr(sys, "frozen", False):
15
+ return Path(sys.executable).parent
16
+ return Path.cwd()
17
+
18
+
19
+ def last_run_path() -> Path:
20
+ return state_directory() / LAST_RUN_FILENAME
pacebar/persistence.py ADDED
@@ -0,0 +1,24 @@
1
+ """Load/save the last run as a deliberately simple JSON file."""
2
+
3
+ import json
4
+
5
+ from .paths import last_run_path
6
+ from .timing import Section
7
+
8
+
9
+ def save_sections(sections) -> None:
10
+ """Persist sections in order. No lateness, no thresholds — kept minimal."""
11
+ data = [{"minutes": int(section.minutes), "name": section.name} for section in sections]
12
+ last_run_path().write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
13
+
14
+
15
+ def load_sections():
16
+ """Return the saved sections, or [] if the file is missing/unreadable."""
17
+ path = last_run_path()
18
+ if not path.exists():
19
+ return []
20
+ try:
21
+ data = json.loads(path.read_text(encoding="utf-8"))
22
+ return [Section(int(item["minutes"]), str(item["name"])) for item in data]
23
+ except Exception:
24
+ return []
pacebar/timing.py ADDED
@@ -0,0 +1,94 @@
1
+ """Pure timing model: stages, a global meeting clock and color state."""
2
+
3
+ from dataclasses import dataclass
4
+ from time import monotonic
5
+
6
+ GREEN = "green"
7
+ YELLOW = "yellow"
8
+ RED = "red"
9
+
10
+
11
+ @dataclass
12
+ class Section:
13
+ minutes: int
14
+ name: str
15
+
16
+ @property
17
+ def seconds(self) -> int:
18
+ return self.minutes * 60
19
+
20
+
21
+ class TimerModel:
22
+ """Drives the countdown using one global clock for the whole meeting.
23
+
24
+ Two pieces of state are enough to place every stage on the same timeline:
25
+
26
+ * ``_start`` — the monotonic instant the meeting began. It is shifted back by
27
+ the initial lateness, so elapsed time starts at ``lateness`` and the first
28
+ stage is already partway down (possibly negative).
29
+ * ``_accumulated`` — the planned seconds of all *completed* stages.
30
+
31
+ The remaining time of the current stage is therefore its planned cumulative
32
+ end minus the real elapsed time. Switching stages never restarts the clock,
33
+ so an overrun (or an early switch) carries straight over to the next stage.
34
+ """
35
+
36
+ def __init__(self, sections, lateness_seconds: int = 0):
37
+ if not sections:
38
+ raise ValueError("at least one section is required")
39
+ self.sections = list(sections)
40
+ self.index = 0
41
+ self._start = monotonic() - lateness_seconds
42
+ self._accumulated = 0.0
43
+
44
+ @property
45
+ def current(self) -> Section:
46
+ return self.sections[self.index]
47
+
48
+ @property
49
+ def is_last(self) -> bool:
50
+ return self.index >= len(self.sections) - 1
51
+
52
+ @property
53
+ def next_section(self):
54
+ return None if self.is_last else self.sections[self.index + 1]
55
+
56
+ def _elapsed(self) -> float:
57
+ return monotonic() - self._start
58
+
59
+ def remaining(self) -> float:
60
+ # Planned cumulative end of the current stage, minus real elapsed time.
61
+ return (self._accumulated + self.current.seconds) - self._elapsed()
62
+
63
+ def advance(self) -> bool:
64
+ """Move to the next stage. Returns False if already on the last one.
65
+
66
+ The real clock keeps running: we only bank the current stage's *planned*
67
+ duration, so any over/under-run rolls into the next stage automatically.
68
+ """
69
+ if self.is_last:
70
+ return False
71
+ self._accumulated += self.current.seconds
72
+ self.index += 1
73
+ return True
74
+
75
+ def go_back(self) -> bool:
76
+ """Roll back one stage — the exact inverse of advance().
77
+
78
+ The global clock keeps running, so the previous stage resumes exactly
79
+ where the timeline puts it: with the time it had left if you switched
80
+ early, or already overdue if you had overrun it.
81
+ """
82
+ if self.index == 0:
83
+ return False
84
+ self.index -= 1
85
+ self._accumulated -= self.current.seconds
86
+ return True
87
+
88
+ def color(self, yellow_percent: float, yellow_seconds: float) -> str:
89
+ remaining = self.remaining()
90
+ if remaining < 0:
91
+ return RED
92
+ # Yellow kicks in at whichever threshold is larger: percent or seconds.
93
+ threshold = max(self.current.seconds * yellow_percent / 100.0, yellow_seconds)
94
+ return YELLOW if remaining <= threshold else GREEN
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: pacebar
3
+ Version: 0.1.0
4
+ Summary: Floating top-strip pacing timer for timeboxed stages — meetings, talks, workouts, even cooking — with countdown and color signalling.
5
+ Author-email: Roman Voronov <roman.voronov.python.developer@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: countdown,meeting,overlay,pacing,presentation,pyside6,timer
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Win32 (MS Windows)
11
+ Classifier: Environment :: X11 Applications :: Qt
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Office/Business
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: pynput>=1.7
24
+ Requires-Dist: pyside6>=6.6
25
+ Provides-Extra: build
26
+ Requires-Dist: pyinstaller>=6.3; extra == 'build'
27
+ Provides-Extra: dev
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # PaceBar
32
+
33
+ ![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)
34
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-555)
35
+ ![License: MIT](https://img.shields.io/badge/License-MIT-green)
36
+ ![Made with Claude Code](https://img.shields.io/badge/Made%20with-Claude%20Code-D97757?logo=anthropic&logoColor=white)
37
+
38
+ A small desktop tool that keeps you on time through any sequence of timed stages —
39
+ a sales call, a talk, a workout, even a recipe. You enter the stages and how many
40
+ minutes each should take; on **Start** a flat,
41
+ always-on-top strip appears across the top of the screen and counts the current
42
+ section down. The strip is **green** while you are on pace, **pastel yellow** when
43
+ the section is almost over, and **pastel red** once you have run over — so you can
44
+ feel the pacing without staring at numbers.
45
+
46
+ Written in Python with **PySide6**. Built cross-platform (Windows + Linux);
47
+ **tested and working on Windows 11**, **not yet tested on Linux** (feedback welcome).
48
+
49
+ ## Screenshots
50
+
51
+ The setup window — one row per section, plus the start / lateness / reset toolbar:
52
+
53
+ ![Setup window](https://raw.githubusercontent.com/Rioran/pacebar/main/docs/images/editor.png)
54
+
55
+ The running strip (here ~2 minutes left on the current section, next section as a
56
+ button), and the minimized square that signals status by color alone:
57
+
58
+ ![Running strip](https://raw.githubusercontent.com/Rioran/pacebar/main/docs/images/strip.png)
59
+
60
+ ![Minimized square](https://raw.githubusercontent.com/Rioran/pacebar/main/docs/images/minimized.png)
61
+
62
+ ## Requirements
63
+
64
+ - [uv](https://docs.astral.sh/uv/) (manages the virtual environment and dependencies)
65
+ - Python 3.10+ (uv can install it for you)
66
+
67
+ ## Install
68
+
69
+ ```bash
70
+ pip install pacebar
71
+ pacebar
72
+ ```
73
+
74
+ This pulls in PySide6 automatically and adds a `pacebar` command. (Prefer the
75
+ standalone exe if you don't want a Python environment at all — see below.)
76
+
77
+ ## Run from source
78
+
79
+ ```bash
80
+ uv sync # create the venv and install dependencies
81
+ uv run pacebar
82
+ ```
83
+
84
+ Or run it in **one click** — double-click the script for your OS:
85
+
86
+ - **Windows:** `scripts\run.bat`
87
+ - **Linux / macOS:** `scripts/run.sh`
88
+
89
+ (`uv sync` runs automatically the first time `uv run` is used.)
90
+
91
+ ## Build a standalone executable
92
+
93
+ One click: double-click `scripts\build.bat` (Windows) or `scripts/build.sh`
94
+ (Linux / macOS). Or run it manually:
95
+
96
+ ```bash
97
+ uv run --extra build pyinstaller --noconfirm --clean pacebar.spec
98
+ ```
99
+
100
+ The build is driven by [`pacebar.spec`](pacebar.spec) (a single
101
+ `--windowed --onefile` build). The result lands in `dist/` (`pacebar.exe` on
102
+ Windows) — one shareable file; it starts a touch slower because it unpacks to a temp
103
+ dir on launch.
104
+
105
+ PyInstaller does **not** cross-compile: it freezes for the OS you run the build on.
106
+ Build on Windows to get the `.exe`, and on Linux (or WSL) to get a Linux binary — each
107
+ runs only on its own platform. For a platform-independent option, use `pip install`
108
+ above instead.
109
+
110
+ ## Lint & format
111
+
112
+ ```bash
113
+ uv run ruff format . # format
114
+ uv run ruff check . # lint
115
+ ```
116
+
117
+ ## Publishing to PyPI (maintainer)
118
+
119
+ ```bash
120
+ uv build # builds the wheel + sdist into dist/
121
+ uv publish dist/pacebar-* # upload (needs a PyPI account + API token)
122
+ ```
123
+
124
+ The `pacebar-*` glob is deliberate: it uploads only the wheel and sdist and skips
125
+ `pacebar.exe`, which also lives in `dist/`. Test on TestPyPI first
126
+ (`uv publish --publish-url https://test.pypi.org/legacy/ ...`), and make sure the
127
+ project name is still available on PyPI before the first real upload.
128
+
129
+ ## How to use
130
+
131
+ **Setup window**
132
+
133
+ - Each row is one section: **minutes** (whole positive number — arrows or type) and a
134
+ **name** (max 30 characters; it blinks red if you try to type more).
135
+ - **Tab / Shift+Tab** move between the two fields of the current row only.
136
+ **Up / Down** (while in the name field) jump to the name field of the row above /
137
+ below. **Enter** adds a new row. The `+` button also adds one; the ▲/▼ control
138
+ reorders; `✕` deletes.
139
+ - **Start** is top-left. Next to it is **minutes late** — how late the meeting is
140
+ actually starting (subtracted from the first section). **Reset** clears everything
141
+ back to one empty row (no confirmation, by design).
142
+ - **Yellow at … % or … s** controls the warning threshold: the strip turns yellow
143
+ when the remaining time drops below *whichever is larger* — that percentage of the
144
+ section, or that many seconds.
145
+
146
+ **Running strip** (left → right)
147
+
148
+ - ◀ **Back** — roll back one section. It resumes on the same global timeline: with the
149
+ time it still had if you switched early, or already overdue if you had overrun it.
150
+ - **Timer** — counts the current section down; format `HH:MM:SS` with the hours group
151
+ hidden when no section is an hour or longer. Goes negative when you run over.
152
+ - **Current section name.**
153
+ - **→ Next section** — click to advance. On the last section it reads **→ End**;
154
+ clicking it exits the program.
155
+ - ▢ **Minimize** — collapses to a small square that signals status by color only.
156
+ Drag it anywhere; click it to restore the full strip.
157
+ - ✕ **Cancel** — quits the program.
158
+
159
+ **Global hotkeys** (work even when another app is focused), laid out like game
160
+ left/right (D = forward, A = back):
161
+
162
+ - **Ctrl+Alt+D** — next section (right); on the last section it triggers **End** (exit)
163
+ - **Ctrl+Alt+A** — back (left)
164
+
165
+ > Chosen to stay clear of common conflicts: plain Alt+A mutes the mic in Zoom and
166
+ > Alt+D jumps to the browser address bar. On non-US layouts Ctrl+Alt equals AltGr,
167
+ > but that only matters while typing into a text field, not during a call.
168
+
169
+ > The strip is intentionally visible to everyone on a screen share — it keeps the
170
+ > whole call honest about time. There is deliberately **no pause**: business calls
171
+ > have hard stops.
172
+
173
+ ## Saved state
174
+
175
+ On every Start the schedule (order, minutes, names — no lateness, no thresholds) is
176
+ written to `pacebar_last_run.json`, and it is loaded back the next time you launch. This
177
+ also makes the tool handy for rehearsing a talk.
178
+
179
+ The file lives **next to the running exe**, so each copy of the app keeps its own
180
+ typical call scenario — drop a copy in a different project folder and it remembers a
181
+ different schedule. (When running from source there is no exe, so it uses the current
182
+ working directory instead.)
183
+
184
+ ## Notes & limitations
185
+
186
+ - Global hotkeys need the `pynput` package (already a dependency). On Linux they work
187
+ under **X11**; under **Wayland** they may be blocked — the on-strip buttons always
188
+ work regardless.
189
+ - Always-on-top is reliable over windowed apps (Zoom/Meet/Teams). A few apps in true
190
+ fullscreen-exclusive mode can still cover any topmost window.
191
+ - The strip width is fixed for the run from the widest section name and worst-case
192
+ timer. An extreme overrun (more minutes over than the longest section) can still
193
+ nudge the timer width.
@@ -0,0 +1,22 @@
1
+ pacebar/__init__.py,sha256=r0xu41rhHPYY0C2AniNOI9Qr2pblUpvBXU33vnTgWC0,87
2
+ pacebar/__main__.py,sha256=-utKlA1Seqn8ksP7rW84kJiBSJRGVblq-qfBljFneKI,426
3
+ pacebar/app.py,sha256=UpwYXMWGA2DAqgjAGfKlvwBZ-TQCot_czxxzGDb1YvY,849
4
+ pacebar/constants.py,sha256=Dv9Oyt_Im7pgOBZdHvd_JBDVUQPqKVDyAQeA7cgUDrg,825
5
+ pacebar/formatting.py,sha256=YwyzeSeKitD_IvB1ou1dMFfvFRWN1YOnaj9ih2nJ2KY,541
6
+ pacebar/paths.py,sha256=9Gf0bgE9MoJ_CIY8ynjS2EBM5l0oTSE7R4AVbHACHGo,510
7
+ pacebar/persistence.py,sha256=5cDTwal9CfLS5YqrYXcVFVzto1EsRDAe6q-aWzMYvvU,815
8
+ pacebar/timing.py,sha256=hWi2jj-03drorCawo5F5a4XfuPWJN5i2IZ3ebhwAfbE,3192
9
+ pacebar/editor/__init__.py,sha256=RK7_XFmgOsEmel2IUX_BrGYBJuzoayMqMjxJA5j3pTk,132
10
+ pacebar/editor/fields.py,sha256=C6H_kXYWzKGt4vfRizkhwIx9wSEtiX9eToPVsCfmjoc,3000
11
+ pacebar/editor/row.py,sha256=LLDCrwsgBrl3RqYkAI5ykAtrbtgaQ1tIqSIzpcFYhw8,3312
12
+ pacebar/editor/window.py,sha256=oP86skaVvkyj4qQHIkMAXUuBDuYIsjxCMKNEfSoZkJw,5938
13
+ pacebar/overlay/__init__.py,sha256=FxOeOt4PQr0_0wKfRnTZhpQn-etl9V8xRgZAMqJDHYw,149
14
+ pacebar/overlay/bar.py,sha256=TBGnFjpCjhXugHFtdvp6XkalMCedk6ME_mzlznuWZMQ,2824
15
+ pacebar/overlay/controller.py,sha256=vBVjakK0Sc1VcgPAm40YJb0KCW1mBfqnSuZyu4R2K18,5727
16
+ pacebar/overlay/hotkeys.py,sha256=jmh8GSGhprJGSW8M9279bDoitHaG_0PIWkr22fr_bgw,3511
17
+ pacebar/overlay/square.py,sha256=qte2_OUjPSL4dPfwz3QTKY4rIph5JNKprHiaHMcUH54,1428
18
+ pacebar-0.1.0.dist-info/METADATA,sha256=emd9sCWG0Ka-8BGgt_M-Yn9tC_6Jzvyfc5aRZZJcups,8185
19
+ pacebar-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ pacebar-0.1.0.dist-info/entry_points.txt,sha256=CZ06IldTiRbuUWpsdIy45sJZbN-hFxepG2Bo0P-OACQ,50
21
+ pacebar-0.1.0.dist-info/licenses/LICENSE,sha256=ihxuew3kQ8FXvaHuD9-fVOX-IXusEbARDiVIST0Dk54,1070
22
+ pacebar-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pacebar = pacebar.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roman Voronov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.