From c3b41ed9b46cf292ecc10e6e19a3074f3a9d0117 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:07:30 +0900 Subject: [PATCH 01/29] feat: add server time calculator --- dzgui/strings/calc.py | 7 + dzgui/views/dialogs/calc.py | 454 ++++++++++++++++++++++++++++++++++++ 2 files changed, 461 insertions(+) create mode 100644 dzgui/strings/calc.py create mode 100644 dzgui/views/dialogs/calc.py diff --git a/dzgui/strings/calc.py b/dzgui/strings/calc.py new file mode 100644 index 0000000..88ba97c --- /dev/null +++ b/dzgui/strings/calc.py @@ -0,0 +1,7 @@ +heading_local_time = "Local time" +heading_server_time = "In-game time" +heading_target_time = "Target time" +heading_remainder_time = "Remainder until target" +heading_adjusted_time = "Adjusted local time" +calculate = "Calculate" +disclaimer = "Times shown are approximations.\nServer time may differ based on time of year in game." diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py new file mode 100644 index 0000000..b9ae280 --- /dev/null +++ b/dzgui/views/dialogs/calc.py @@ -0,0 +1,454 @@ +import gi +from dataclasses import dataclass +from datetime import datetime, time, timedelta +from typing import Literal + +from dzgui.views.components.labels import BoldLabel +from dzgui.strings import calc +from dzgui.views.components.box import VBox + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402 + +DAY_START_TIME = time(hour=6, minute=0, second=0) +DAY_END_TIME = time(hour=17, minute=59, second=59) +NIGHT_START_TIME = time(hour=18, minute=0, second=0) +NIGHT_END_TIME = time(hour=5, minute=59, second=59) +EXTENDED_TIME_FORMAT = "%H:%M:%S" + + +@dataclass(slots=True, frozen=True) +class Time: + time: str + day_accel: float + night_accel: float + + +class Emitter(GObject.GObject): + def __init__(self) -> None: + super().__init__() + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object, object)) + def local_time_incremented(self, time_now: datetime, elapsed: timedelta) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def server_time_incremented(self, server_time: datetime) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=()) + def calculate_button_clicked(self) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def target_time_changed(self, time: timedelta) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def remaining_time_changed(self, time: datetime) -> None: + pass + + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def adjusted_local_time_changed(self, time: datetime) -> None: + pass + + +class LocalClock: + def __init__(self, emitter: Emitter) -> None: + self.emitter = emitter + self.start_time = datetime.now() + GLib.timeout_add(500, self.check_time) + + # FIXME: sometimes skips 2s + def get_time(self) -> str: + return datetime.now().time().strftime(EXTENDED_TIME_FORMAT) + + def get_elapsed_time(self) -> timedelta: + return datetime.now() - self.start_time + + def check_time(self) -> Literal[True]: + time_now = datetime.now() + elapsed = datetime.now() - self.start_time + self.emitter.emit("local_time_incremented", time_now, elapsed) + return True + + +class ServerClock: + def __init__( + self, server_time: Time, emitter: Emitter, local_clock: LocalClock + ) -> None: + + self.local_clock = local_clock + self.emitter = emitter + + hour, minute = self.split_time(server_time) + self.day_accel = server_time.day_accel + self.night_accel = server_time.night_accel + + self.start_time = datetime.combine(datetime.today(), time(hour, minute)) + self.cur_time = datetime.combine(datetime.today(), time(hour, minute)) + + # NOTE: at highest possible accel interval (64 * 64), real time is 0.876 seconds + if self.is_night(): + GLib.timeout_add(500, self.increment_night) + else: + GLib.timeout_add(500, self.increment_day) + + @staticmethod + def split_time(server_time: Time) -> tuple[int, int]: + split = server_time.time.split(":") + return int(split[0]), int(split[1]) + + def _increment_with_offset(self, offset: float) -> None: + elapsed = self.local_clock.get_elapsed_time() + virtual_elapsed = elapsed * offset + self.cur_time = self.start_time + virtual_elapsed + self.emitter.emit("server_time_incremented", self.cur_time) + + def increment_day(self) -> bool: + if self.is_night(): + GLib.timeout_add(500, self.increment_night) + return False + offset = self.day_accel + self._increment_with_offset(offset) + return True + + def increment_night(self) -> bool: + if not self.is_night(): + GLib.timeout_add(500, self.increment_day) + return False + offset = self.night_accel * self.day_accel + self._increment_with_offset(offset) + return True + + def is_night(self) -> bool: + return ( + self.cur_time.time() >= NIGHT_START_TIME + or self.cur_time.time() <= NIGHT_END_TIME + ) + + def get_values(self) -> tuple[int, int]: + return self.cur_time.hour, self.cur_time.minute + + def get_time(self) -> time: + return self.cur_time.time() + + def get_cycle_remainder(self, accel: bool = True) -> float: + # NOTE: calc from cycle start time to current point + if self.is_night(): + cutoff = DAY_START_TIME + offset = self.day_accel * self.night_accel + else: + cutoff = NIGHT_START_TIME + offset = self.day_accel + start = datetime.combine(self.cur_time, cutoff) + + if accel is False: + offset = 1 + return (start - self.cur_time).total_seconds() / offset + + def get_full_cycle(self, end_time: time, start_time: time) -> float: + end = datetime.combine(self.cur_time, end_time) + start = datetime.combine(self.cur_time, start_time) + if start > end: + end += timedelta(days=1) + return (end - start).total_seconds() + + def get_day_cycle(self) -> float: + return self.get_full_cycle(DAY_END_TIME, DAY_START_TIME) + + def get_night_cycle(self) -> float: + return self.get_full_cycle(NIGHT_END_TIME, NIGHT_START_TIME) + + def get_delta(self, picker_time: time, start_time: time) -> float: + start = datetime.combine(self.cur_time, start_time) + picker_date = datetime.combine(self.cur_time, picker_time) + if start > picker_date: + picker_date += timedelta(days=1) + delta = (picker_date - start).total_seconds() + return delta + + def get_next_cycle(self) -> float: + if self.is_night(): + return self.get_day_cycle() / self.day_accel + else: + return self.get_night_cycle() / (self.day_accel * self.night_accel) + + def get_remaining_delta(self, picker_time: time, is_night: bool) -> float: + if is_night: + start_time = NIGHT_START_TIME + offset = self.day_accel * self.night_accel + else: + start_time = DAY_START_TIME + offset = self.day_accel + return self.get_delta(picker_time, start_time) / offset + + def calc_delta(self, picker_time: time, is_night: bool) -> timedelta: + # NOTE: time spans two full cycles + if self.get_time() > picker_time: + to_end = self.get_cycle_remainder() + next_cycle = self.get_next_cycle() + delta = self.get_remaining_delta(picker_time, is_night) + total = to_end + next_cycle + delta + else: + # TODO: fix calculation + picker_date = datetime.combine(self.cur_time, picker_time) + # NOTE: strip sub-minute values + new = self.cur_time.replace(second=0, microsecond=0) + delta = (picker_date - new).total_seconds() + remainder_till_cycle_end = self.get_cycle_remainder(accel=False) + if delta <= remainder_till_cycle_end: + if self.is_night(): + total = delta / (self.day_accel * self.night_accel) + else: + total = delta / self.day_accel + else: + total = self.get_remaining_delta(picker_time, is_night) + + total = round(total) + td = timedelta(seconds=total) + return td + + +class GenericAdjustedClock: + def __init__(self, emitter: Emitter, signal: str) -> None: + self.signal = signal + self.emitter = emitter + self.emitter.connect("target_time_changed", self._on_target_time_changed) + + def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: + if isinstance(self, AdjustedClock): + self.start_time = datetime.now() + adjusted = self.start_time + time + self.emitter.emit(self.signal, adjusted) + + def get_time(self) -> str: + return self.start_time.strftime(EXTENDED_TIME_FORMAT) + + +class RemainderClock(GenericAdjustedClock): + def __init__(self, emitter: Emitter) -> None: + super().__init__(emitter, signal="remaining_time_changed") + self.start_time = datetime.combine( + datetime.now(), time(hour=0, minute=0, second=0) + ) + + +class AdjustedClock(GenericAdjustedClock): + def __init__(self, emitter: Emitter) -> None: + super().__init__(emitter, signal="adjusted_local_time_changed") + self.start_time = datetime.now() + + +class VerticalSpinBox(Gtk.SpinButton): + def __init__(self, value: int, upper: int) -> None: + super().__init__( + adjustment=Gtk.Adjustment( + value=value, + lower=0, + upper=upper, + step_increment=1, + page_increment=1, + page_size=0, + ), + orientation=Gtk.Orientation.VERTICAL, + ) + + +class HourSpinBox(VerticalSpinBox): + def __init__(self, hour: int) -> None: + super().__init__(value=hour, upper=23) + + +class MinuteSpinBox(VerticalSpinBox): + def __init__(self, minute: int) -> None: + super().__init__(value=minute, upper=59) + + self.get_adjustment().set_page_increment(10) + + +class TimePicker(Gtk.Box): + def __init__(self, hour: int, minute: int) -> None: + super().__init__( + orientation=Gtk.Orientation.HORIZONTAL, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.CENTER, + ) + self.hour_spin = HourSpinBox(hour) + self.minute_spin = MinuteSpinBox(minute) + self.add(self.hour_spin) + self.add(self.minute_spin) + + self.hour_spin.connect("output", self._on_output) + self.minute_spin.connect("output", self._on_output) + + def _on_output(self, spin_button: VerticalSpinBox) -> Literal[True]: + val = spin_button.get_value() + spin_button.set_text(f"{int(val):02d}") + # NOTE: indicates that rendering completed + return True + + def get_values(self) -> tuple[int, int]: + hour = self.get_hour() + minute = self.get_minute() + return hour, minute + + def get_hour(self) -> int: + return int(self.hour_spin.get_adjustment().get_value()) + + def get_minute(self) -> int: + return int(self.minute_spin.get_adjustment().get_value()) + + def get_time(self) -> time: + return time(hour=self.get_hour(), minute=self.get_minute()) + + def set_time(self, hour: int, minute: int) -> None: + self.hour_spin.get_adjustment().set_value(hour) + self.minute_spin.get_adjustment().set_value(minute) + + def is_night(self) -> bool: + t = self.get_time() + return t >= NIGHT_START_TIME or t <= NIGHT_END_TIME + + +class AdjustedTimeFrame(Gtk.Frame): + def __init__(self, emitter: Emitter) -> None: + super().__init__(margin_start=50, margin_end=50) + + self.emitter = emitter + self.emitter.connect( + "adjusted_local_time_changed", self._on_adjusted_time_changed + ) + self.emitter.connect("remaining_time_changed", self._on_remainder_changed) + + self.remainder_clock = RemainderClock(emitter) + self.remainder_heading = BoldLabel(calc.heading_remainder_time) + self.remaining_time = Gtk.Label(label=self.remainder_clock.get_time()) + + self.adjusted_clock = AdjustedClock(emitter) + self.adjusted_heading = BoldLabel(calc.heading_adjusted_time) + self.adjusted_local_time = Gtk.Label(label=self.adjusted_clock.get_time()) + + flowbox = Gtk.FlowBox( + row_spacing=20, + column_spacing=50, + min_children_per_line=2, + max_children_per_line=2, + margin=15, + halign=Gtk.Align.CENTER, + selection_mode=Gtk.SelectionMode.NONE, + ) + flowbox.add(self.remainder_heading) + flowbox.add(self.adjusted_heading) + flowbox.add(self.remaining_time) + flowbox.add(self.adjusted_local_time) + + recalc = Gtk.Button( + label=calc.calculate, halign=Gtk.Align.CENTER, margin_bottom=20 + ) + recalc.connect("clicked", self._on_recalc_clicked) + + vbox = VBox() + vbox.extend([flowbox, recalc]) + self.add(vbox) + + def _on_remainder_changed(self, emitter: Emitter, time: datetime) -> None: + strtime = str(time.strftime(EXTENDED_TIME_FORMAT)) + self.remaining_time.set_text(strtime) + + def _on_adjusted_time_changed(self, emitter: Emitter, time: datetime) -> None: + strtime = str(time.strftime(EXTENDED_TIME_FORMAT)) + self.adjusted_local_time.set_text(strtime) + + def _on_recalc_clicked(self, button: Gtk.Button) -> None: + self.emitter.emit("calculate_button_clicked") + + +class TimePickerFrame(Gtk.Frame): + def __init__(self, emitter: Emitter, stime: Time) -> None: + super().__init__(margin_start=50, margin_end=50) + + self.emitter = emitter + self.emitter.connect("local_time_incremented", self._on_local_clock_increment) + self.emitter.connect("server_time_incremented", self._on_server_clock_increment) + self.emitter.connect( + "calculate_button_clicked", self._on_calculate_button_clicked + ) + + self.local_clock = LocalClock(self.emitter) + local_clock_time = self.local_clock.get_time() + + self.server_time = ServerClock(stime, self.emitter, self.local_clock) + hour, minute = self.server_time.get_values() + self.picker = TimePicker(hour, minute) + + self.local_timelabel = Gtk.Label(label=local_clock_time) + self.server_timelabel = Gtk.Label(label=stime.time, halign=Gtk.Align.CENTER) + + h0 = BoldLabel(calc.heading_local_time) + h1 = BoldLabel(calc.heading_server_time) + h2 = BoldLabel(calc.heading_target_time) + + flowbox = Gtk.FlowBox( + row_spacing=10, + column_spacing=50, + min_children_per_line=3, + max_children_per_line=3, + margin=15, + halign=Gtk.Align.CENTER, + selection_mode=Gtk.SelectionMode.NONE, + ) + flowbox.add(h0) + flowbox.add(h1) + flowbox.add(h2) + flowbox.add(self.local_timelabel) + flowbox.add(self.server_timelabel) + flowbox.add(self.picker) + + self.add(flowbox) + + def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None: + t = time_now.time().strftime("%H:%M") + self.server_timelabel.set_text(t) + + def _on_local_clock_increment( + self, emitter: Emitter, time_now: datetime, elapsed: timedelta + ) -> None: + t = time_now.time().strftime(EXTENDED_TIME_FORMAT) + self.local_timelabel.set_text(t) + + def _on_calculate_button_clicked(self, button: Gtk.Button) -> None: + if (self.picker.get_values()) == (self.server_time.get_values()): + total_time = timedelta(seconds=0) + else: + picker_time = self.picker.get_time() + is_night = self.picker.is_night() + total_time = self.server_time.calc_delta(picker_time, is_night) + self.emitter.emit("target_time_changed", total_time) + + +class ServerTimeCalculator(Gtk.ScrolledWindow): + def __init__(self, stime: Time) -> None: + super().__init__(propagate_natural_width=True) + + self.emitter = Emitter() + outerbox = Gtk.Box( + halign=Gtk.Align.CENTER, + valign=Gtk.Align.START, + spacing=20, + orientation=Gtk.Orientation.VERTICAL, + ) + time_picker_frame = TimePickerFrame(self.emitter, stime) + adjusted_time_frame = AdjustedTimeFrame(self.emitter) + disclaimer = Gtk.Label( + label=calc.disclaimer, + justify=Gtk.Justification.CENTER, + ) + for el in time_picker_frame, adjusted_time_frame, disclaimer: + outerbox.add(el) + self.add(outerbox) + + +# TEST: write tests with/without live clock increment +# FIXME: with accel of (1, 1), a 60min change converts to 55 mins instead of 59/60 +# FIXME: real time until target time is not zero padded From b7e8982f4046f65c0a9743b18e98be1d4b4c2053 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:08:03 +0900 Subject: [PATCH 02/29] feat: pack calculator in Gtk.Stack --- dzgui/views/dialogs/servers.py | 68 ++++++++++++++++++++++------------ 1 file changed, 44 insertions(+), 24 deletions(-) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index d957158..b82895e 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -1,20 +1,21 @@ +import gi import html from typing import Self, TYPE_CHECKING -from dzgui.const.constants import EXPAND, FILL +from dzgui.const.constants import CALCULATOR from dzgui.const.enum import ContextMenuGroup from dzgui.model.model_factory import ModelFactory from dzgui.util import css -from dzgui.util import strings from dzgui.util.format import format_hyperlinks, format_server_mods -from dzgui.strings import server_mods +from dzgui.strings import server_mods, dialogs +from dzgui.views.components.buttons import IconTextButton +from dzgui.views.components.box import VBox +from dzgui.views.dialogs.calc import Time, ServerTimeCalculator from dzgui.views.dialogs.generic import GenericDialog from dzgui.views.trees.tree_base import TreeView -import gi - gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib, Gdk, GObject, Pango # noqa +from gi.repository import Gtk, Gdk, Pango # noqa if TYPE_CHECKING: from dzgui.api.servers import Details @@ -46,12 +47,19 @@ class ServerDialog(GenericDialog): self.connect("response", self._on_response) self.view.connect("key-press-event", self._on_keypress) - self.scrollable_tree = Gtk.ScrolledWindow() + self.scrollable_tree = Gtk.ScrolledWindow(vexpand=True) self.scrollable_tree.add(self.view) - self.scrollable_tree.set_size_request(700, 400) + self.scrollable_tree.set_size_request(700, 500) + + self.stack = Gtk.Stack() + self.content_box = VBox(10) + self.content_box.set_valign(Gtk.Align.FILL) + self.content_box.add(self.scrollable_tree) + + self.stack.add_named(self.content_box, "METADATA") self.content = self.get_content_area() - self.content.pack_start(self.scrollable_tree, EXPAND, FILL, 0) + self.content.pack_start(self.stack, expand=True, fill=True, padding=0) def _on_keypress(self, view: Gtk.TreeView, event: Gdk.EventKey) -> bool: # NOTE: ESC normally unfocuses treeview instead of destroying dialog @@ -66,9 +74,7 @@ class ServerDialog(GenericDialog): class ServerDetailsDialog(ServerDialog): def __init__(self, controller: "Controller", details: "Details"): - # TODO: server name should also be packed in details struct - name = controller.get_server_name() - super().__init__(controller, strings.server_details, name, menu=None) + super().__init__(controller, dialogs.server_details, details.name, menu=None) self.store = Gtk.ListStore(str, str, Pango.Weight) self.view.connect("row-activated", self._on_row_activated) @@ -85,14 +91,10 @@ class ServerDetailsDialog(ServerDialog): column.set_sort_column_id(i) column.set_expand(True) - # NOTE: constrain tree area to logical height of metadata - # leaving room for server message - self.scrollable_tree.set_size_request(700, 150) - # TODO: make "Server message" text boldface - scrollable_message = Gtk.ScrolledWindow() - desc = Gtk.Label(label=strings.server_message, valign=Gtk.Align.START) - css.add_class(desc, "details-heading") + scrollable_message = Gtk.ScrolledWindow(vexpand=True) + desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START) + css.add_class(desc, "server-subheading") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, halign=Gtk.Align.CENTER) self.description = Gtk.Label(justify=Gtk.Justification.CENTER, wrap=True) sep = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL) @@ -100,11 +102,7 @@ class ServerDetailsDialog(ServerDialog): for el in desc, sep, self.description: box.add(el) scrollable_message.add(box) - - self.pack(scrollable_message) - - if details.data is None: - return + self.content_box.add(scrollable_message) for row in details.data: self.store.append(row + [Pango.Weight.BOLD]) @@ -113,8 +111,30 @@ class ServerDetailsDialog(ServerDialog): text = format_hyperlinks(text) self.description.set_markup(html.escape(text)) + calc_button = IconTextButton(CALCULATOR, dialogs.toggle_calc) + calc_button.connect("clicked", self._on_calc_pressed) + calc_button.set_halign(Gtk.Align.END) + calc_button.set_valign(Gtk.Align.START) + self.content.pack_start(calc_button, False, False, 0) + self.content.reorder_child(calc_button, 2) + + self.scrollable_tree.set_size_request(700, 250) + self.calc = ServerTimeCalculator(Time(details.gametime, details.day_accel, details.night_accel)) + self.stack.add_named(self.calc, "CALCULATOR") + + self.connect("map", self._on_map) self.show_all() + def _on_map(self, dialog: Self) -> None: + if (widget := self.get_widget_for_response(Gtk.ResponseType.OK)): + widget.grab_focus() + + def _on_calc_pressed(self, button: Gtk.Button) -> None: + if self.stack.get_visible_child_name() == "METADATA": + self.stack.set_visible_child_name("CALCULATOR") + else: + self.stack.set_visible_child_name("METADATA") + def _on_row_activated( self, treeview: Gtk.TreeView, From 4aaf7fe1953a630a3c3ce2360a3771e8482ba2a1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:08:32 +0900 Subject: [PATCH 03/29] chore: update strings files --- dzgui/strings/dialogs.py | 5 +++++ dzgui/util/strings.py | 2 -- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dzgui/strings/dialogs.py b/dzgui/strings/dialogs.py index 3ab9a04..64fba5f 100644 --- a/dzgui/strings/dialogs.py +++ b/dzgui/strings/dialogs.py @@ -23,3 +23,8 @@ custom_mod_dialog = "Set location to a custom mods folder" debug_heading = "Debug" debug_secondary = "Launch arguments" + +toggle_calc = "Toggle calculator" + +server_details = "Server details" +server_message = "Server message" diff --git a/dzgui/util/strings.py b/dzgui/util/strings.py index da28c18..1207e4d 100644 --- a/dzgui/util/strings.py +++ b/dzgui/util/strings.py @@ -11,7 +11,6 @@ delimiter = "␞" # GenericDialog main_menu = "Return to main menu" exit_app = "Exit" -server_details = "Server details" modlist = "Modlist" input_required = "User input required" confirm = "Confirmation" @@ -59,7 +58,6 @@ browser_cols = [ ] # DetailsDialog -server_message = "Server message" workshop = "Enter/double click a row to open in Steam Workshop." # LanDialog From ee31dce6e619f2f344f092622dfb0e2c7444f9c3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:09:51 +0900 Subject: [PATCH 04/29] chore: add icon constant --- dzgui/const/constants.py | 1 + dzgui/data/app.css | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 0ce8702..6089941 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -37,6 +37,7 @@ HEX_RED = "#FF0000" HEX_ORANGE = "#FFAC1C" # FIXME: normalize icon names +CALCULATOR = "accessories-calculator-symbolic" CARET_DOWN = "go-down-symbolic" CARET_UP = "go-up-symbolic" CLIPBOARD = "edit-copy-symbolic" diff --git a/dzgui/data/app.css b/dzgui/data/app.css index 9075dec..71f189c 100644 --- a/dzgui/data/app.css +++ b/dzgui/data/app.css @@ -24,6 +24,10 @@ .left-label { font-size: 1.3rem; } +.server-subheading { + font-size: 1.1rem; + font-weight: 700; +} .details-heading { font-size: 1.2rem } From 1ebf253e9917514b3ab307a324e7c956350c5338 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:10:20 +0900 Subject: [PATCH 05/29] chore: apply linter changes --- dzgui/views/components/box.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dzgui/views/components/box.py b/dzgui/views/components/box.py index 4e1772f..d581255 100644 --- a/dzgui/views/components/box.py +++ b/dzgui/views/components/box.py @@ -24,6 +24,7 @@ class VBox(GenericBox): def __init__(self, spacing: int = 0) -> None: super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=spacing) + class ShortHBox(HBox): def __init__(self, widget: Gtk.Widget) -> None: super().__init__(spacing=5) From 6d046ee719dcc41d91f0c3038574d5e90ff14119 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:10:50 +0900 Subject: [PATCH 06/29] change: restructure Details dataclass --- dzgui/api/servers.py | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 6f56094..6347230 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -54,9 +54,12 @@ class Res: @dataclass(slots=True, frozen=True) class Details: - data: Union[list, None] + data: list[list[str]] + name: str description: str - success: bool + gametime: str + day_accel: float + night_accel: float @dataclass(slots=True, frozen=True) @@ -277,43 +280,41 @@ def query_direct(ip: str, qport: int, timeout: float = 3.0) -> dict[str, Any] | info = a2s.info((ip, qport), timeout) return source_info_to_dict(ip, qport, info) except Exception as e: - logger.warning(e) + logger.debug(e) return None -def get_details(record: Record) -> Details: - # TODO: consolidate try/except statements +def get_details(record: Record) -> Details | None: ip = record.ip qport = record.qport default_str = strings.none_provided try: info = a2s.info((ip, qport)) - except (TimeoutError, a2s.exceptions.BrokenMessageError): - return Details(None, default_str, False) - try: rules = dayzquery.dayz_rules((ip, qport)) - except Exception: - return Details(None, default_str, False) - - try: keywords = info.keywords.split(",") - except AttributeError: - return Details(None, default_str, False) + except Exception as e: + logger.critical(e) + return None battleye = strings.disabled if "battleye" in keywords: battleye = strings.enabled - day_accel = 0.0 - night_accel = 0.0 + name = info.server_name + day_accel = 1.0 + night_accel = 1.0 + gametime = "00:00" for keyword in keywords: if "etm" in keyword: day_accel = float(keyword.lstrip("etm")) day_accel = float(f"{day_accel:g}") - if "entm" in keywords: + if "entm" in keyword: night_accel = float(keyword.lstrip("entm")) night_accel = float(f"{night_accel:g}") + time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)" + if re.match(time_reg, keyword): + gametime = keyword try: password = info.password_protected @@ -379,7 +380,7 @@ def get_details(record: Record) -> Details: ["Version", version], ] - return Details(rows, description, True) + return Details(rows, name, description, gametime, day_accel, night_accel) def ping(ip: str, qport: int) -> int: From 32395812a03d7f4dbbd1d37f047702cc2f213cc7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:11:02 +0900 Subject: [PATCH 07/29] change: support new dataclass format --- dzgui/managers/connection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py index 7d2047e..2fbd91a 100644 --- a/dzgui/managers/connection.py +++ b/dzgui/managers/connection.py @@ -235,7 +235,7 @@ class ConnectionManager: @call_on_thread(dialog.querying) def query_details(self, record: Servers.Record) -> None: details = Servers.get_details(record) - if details.success is False: + if details is None: self.thread_man.set_cleanup_func( StoredFunc(self._server_timeout), destroy_first=True ) From fef8c69f383710ba5aba8d1b77d7b5c7ff4c52f7 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:21:11 +0900 Subject: [PATCH 08/29] change: synchronize local and remainder clocks --- dzgui/views/dialogs/calc.py | 44 ++++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index b9ae280..4e90137 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -226,19 +226,51 @@ class GenericAdjustedClock: return self.start_time.strftime(EXTENDED_TIME_FORMAT) -class RemainderClock(GenericAdjustedClock): +class RemainderClock: def __init__(self, emitter: Emitter) -> None: - super().__init__(emitter, signal="remaining_time_changed") - self.start_time = datetime.combine( - datetime.now(), time(hour=0, minute=0, second=0) + self.emitter = emitter + self.emitter.connect("target_time_changed", self._on_target_time_changed) + self.emitter.connect("local_time_incremented", self._on_local_time_incremented) + self.time = datetime.combine(datetime.now(), time(hour=0, minute=0, second=0)) + + self.previous = timedelta(0) + + def _on_local_time_incremented( + self, emitter: Emitter, time: datetime, elapsed: timedelta + ) -> None: + if self.time == datetime.min.time(): + return + + delta = elapsed - self.previous + # NOTE: clamp to midnight + self.time = max( + self.time - delta, self.time.replace(hour=0, minute=0, second=0) ) + self.previous = elapsed + self.emitter.emit("remaining_time_changed", self.time) + + def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: + self.time += time + self.emitter.emit("remaining_time_changed", self.time) + + def get_time(self) -> str: + return self.time.strftime(EXTENDED_TIME_FORMAT) -class AdjustedClock(GenericAdjustedClock): +class AdjustedClock: def __init__(self, emitter: Emitter) -> None: - super().__init__(emitter, signal="adjusted_local_time_changed") + self.emitter = emitter + self.emitter.connect("target_time_changed", self._on_target_time_changed) self.start_time = datetime.now() + def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: + self.start_time = datetime.now() + adjusted = self.start_time + time + self.emitter.emit("adjusted_local_time_changed", adjusted) + + def get_time(self) -> str: + return self.start_time.strftime(EXTENDED_TIME_FORMAT) + class VerticalSpinBox(Gtk.SpinButton): def __init__(self, value: int, upper: int) -> None: From eb5d5d518160c49f418614eb03035708703b4a76 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:26:01 +0900 Subject: [PATCH 09/29] fix: reset time on subsequent invocations --- dzgui/views/dialogs/calc.py | 6 +++++- dzgui/views/dialogs/servers.py | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 4e90137..27d4a36 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -231,10 +231,13 @@ class RemainderClock: self.emitter = emitter self.emitter.connect("target_time_changed", self._on_target_time_changed) self.emitter.connect("local_time_incremented", self._on_local_time_incremented) - self.time = datetime.combine(datetime.now(), time(hour=0, minute=0, second=0)) + self.reset_time() self.previous = timedelta(0) + def reset_time(self) -> None: + self.time = datetime.combine(datetime.now(), time(hour=0, minute=0, second=0)) + def _on_local_time_incremented( self, emitter: Emitter, time: datetime, elapsed: timedelta ) -> None: @@ -250,6 +253,7 @@ class RemainderClock: self.emitter.emit("remaining_time_changed", self.time) def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: + self.reset_time() self.time += time self.emitter.emit("remaining_time_changed", self.time) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index b82895e..6166aee 100644 --- a/dzgui/views/dialogs/servers.py +++ b/dzgui/views/dialogs/servers.py @@ -91,7 +91,6 @@ class ServerDetailsDialog(ServerDialog): column.set_sort_column_id(i) column.set_expand(True) - scrollable_message = Gtk.ScrolledWindow(vexpand=True) desc = Gtk.Label(label=dialogs.server_message, valign=Gtk.Align.START) css.add_class(desc, "server-subheading") @@ -119,14 +118,16 @@ class ServerDetailsDialog(ServerDialog): self.content.reorder_child(calc_button, 2) self.scrollable_tree.set_size_request(700, 250) - self.calc = ServerTimeCalculator(Time(details.gametime, details.day_accel, details.night_accel)) + self.calc = ServerTimeCalculator( + Time(details.gametime, details.day_accel, details.night_accel) + ) self.stack.add_named(self.calc, "CALCULATOR") self.connect("map", self._on_map) self.show_all() def _on_map(self, dialog: Self) -> None: - if (widget := self.get_widget_for_response(Gtk.ResponseType.OK)): + if widget := self.get_widget_for_response(Gtk.ResponseType.OK): widget.grab_focus() def _on_calc_pressed(self, button: Gtk.Button) -> None: From f135f6a35dc457d66219c4f2b3ea24be0e4bef5a Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:26:45 +0900 Subject: [PATCH 10/29] drop: GenericAdjustedClock --- dzgui/views/dialogs/calc.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 27d4a36..53b0367 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -210,22 +210,6 @@ class ServerClock: return td -class GenericAdjustedClock: - def __init__(self, emitter: Emitter, signal: str) -> None: - self.signal = signal - self.emitter = emitter - self.emitter.connect("target_time_changed", self._on_target_time_changed) - - def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: - if isinstance(self, AdjustedClock): - self.start_time = datetime.now() - adjusted = self.start_time + time - self.emitter.emit(self.signal, adjusted) - - def get_time(self) -> str: - return self.start_time.strftime(EXTENDED_TIME_FORMAT) - - class RemainderClock: def __init__(self, emitter: Emitter) -> None: self.emitter = emitter From 76ed7f24513830630dae3dc9b51376a5e7a46df1 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:38:37 +0900 Subject: [PATCH 11/29] change: use greater precision only when setting target time --- dzgui/views/dialogs/calc.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 53b0367..30f06c4 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -215,12 +215,16 @@ class RemainderClock: self.emitter = emitter self.emitter.connect("target_time_changed", self._on_target_time_changed) self.emitter.connect("local_time_incremented", self._on_local_time_incremented) - self.reset_time() + self.reset_time(True) self.previous = timedelta(0) - def reset_time(self) -> None: - self.time = datetime.combine(datetime.now(), time(hour=0, minute=0, second=0)) + def reset_time(self, zero_out: bool = False) -> None: + now = datetime.now() + second, ms = (0, 0) if zero_out else (now.second, now.microsecond) + self.time = datetime.combine( + now, time(hour=0, minute=0, second=second, microsecond=ms) + ) def _on_local_time_incremented( self, emitter: Emitter, time: datetime, elapsed: timedelta @@ -320,7 +324,13 @@ class TimePicker(Gtk.Box): return int(self.minute_spin.get_adjustment().get_value()) def get_time(self) -> time: - return time(hour=self.get_hour(), minute=self.get_minute()) + d = datetime.now() + return time( + hour=self.get_hour(), + minute=self.get_minute(), + second=d.second, + microsecond=d.microsecond, + ) def set_time(self, hour: int, minute: int) -> None: self.hour_spin.get_adjustment().set_value(hour) From 32b8c5cbc5558d3cd2f890dcc5e728807daf8300 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:40:25 +0900 Subject: [PATCH 12/29] chore: remove resolved comments --- dzgui/views/dialogs/calc.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 30f06c4..ada798d 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -59,7 +59,6 @@ class LocalClock: self.start_time = datetime.now() GLib.timeout_add(500, self.check_time) - # FIXME: sometimes skips 2s def get_time(self) -> str: return datetime.now().time().strftime(EXTENDED_TIME_FORMAT) @@ -191,7 +190,6 @@ class ServerClock: delta = self.get_remaining_delta(picker_time, is_night) total = to_end + next_cycle + delta else: - # TODO: fix calculation picker_date = datetime.combine(self.cur_time, picker_time) # NOTE: strip sub-minute values new = self.cur_time.replace(second=0, microsecond=0) @@ -477,8 +475,3 @@ class ServerTimeCalculator(Gtk.ScrolledWindow): for el in time_picker_frame, adjusted_time_frame, disclaimer: outerbox.add(el) self.add(outerbox) - - -# TEST: write tests with/without live clock increment -# FIXME: with accel of (1, 1), a 60min change converts to 55 mins instead of 59/60 -# FIXME: real time until target time is not zero padded From e2548211051aa5c8903f744ece8da7cee91c7af2 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:03:22 +0900 Subject: [PATCH 13/29] change: update time range assertions --- dzgui/managers/filter.py | 4 ++-- dzgui/model/proxy_model.py | 4 ++-- tests/test_time.py | 4 +++- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/dzgui/managers/filter.py b/dzgui/managers/filter.py index febb861..7dd2a21 100644 --- a/dzgui/managers/filter.py +++ b/dzgui/managers/filter.py @@ -32,10 +32,10 @@ class FilterManager: # TODO: strings self.tooltips = { strings.filter_1pp: "First-person perspective", - strings.filter_day: "In-game time between 0700 and 1659", + strings.filter_day: "In-game time between 0600 and 1759", strings.filter_empty: "Servers with no players", strings.filter_3pp: "Third-person perspective", - strings.filter_night: "In-game time between 1700 and 0659", + strings.filter_night: "In-game time between 1800 and 0559", strings.filter_full: "Servers with no open slots", strings.filter_official: "Bohemia official servers", strings.filter_nonascii: "Server names using non-standard, complex glyphs", diff --git a/dzgui/model/proxy_model.py b/dzgui/model/proxy_model.py index 44a03de..ce49e85 100644 --- a/dzgui/model/proxy_model.py +++ b/dzgui/model/proxy_model.py @@ -12,8 +12,8 @@ if TYPE_CHECKING: from dzgui.api.servers import Record from dzgui.model.servers import NewPlayerCount -DAY_REG = r"([0][7-9]|[1][0-6])" -NIGHT_REG = r"([0][0-6]|[1][7-9]|[2][0-3])" +DAY_REG = r"([0][6-9]|[1][0-7])" +NIGHT_REG = r"([0][0-5]|[1][8-9]|[2][0-3])" class ProxyModelManager: diff --git a/tests/test_time.py b/tests/test_time.py index 210068a..4bc8fcb 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,7 +1,9 @@ import re from dzgui.model.proxy_model import DAY_REG, NIGHT_REG +import pytest +pytestmark = pytest.mark.FOO def iterate(h: str, r: str) -> None: for m in range(60): @@ -18,6 +20,6 @@ def test_day() -> None: def test_night() -> None: for h in range(24): - if 6 < h < 17: + if 5 < h < 18: continue iterate(h, NIGHT_REG) From ac286f4a72fdf9c57bdc6b64037a78da9950ee90 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:00:34 +0900 Subject: [PATCH 14/29] chore: add metadata test --- tests/test_server_metadata.py | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_server_metadata.py diff --git a/tests/test_server_metadata.py b/tests/test_server_metadata.py new file mode 100644 index 0000000..bdde14b --- /dev/null +++ b/tests/test_server_metadata.py @@ -0,0 +1,45 @@ +import pytest +import re + +from dzgui.api.servers import Details, Record, get_details + + +@pytest.fixture +def details() -> Details: + """ + Static official server, metadata is expected to be invariate + """ + r = Record("85.190.157.111", 10400, 10401) + return get_details(r) + + +def test_invalid_server() -> None: + r = Record("127.0.0.1", 10400, 10401) + assert get_details(r) is None + + +@pytest.mark.parametrize("key, expect", [("day_accel", 2.3), ("night_accel", 6.8)]) +def test_time_accel(details, key, expect) -> None: + assert getattr(details, key) == expect + + +def test_gametime(details) -> None: + time_reg = r"^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]$)" + match = re.match(time_reg, details.gametime) + assert match is not None + + +def test_metadata(details) -> None: + for row in details.data: + label, value = row + match label: + case "Password": + assert value == "Disabled" + case "Platform": + assert value == "Windows" + case "Battleye": + assert value == "Enabled" + case "Valve Anti-Cheat": + assert value == "Enabled" + case _: + pass From cb5c233f4577c5225055efbfb786aa7fa78c6c7d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:25:37 +0900 Subject: [PATCH 15/29] change: simplify calculation logic --- dzgui/views/dialogs/calc.py | 153 +++++++++++++----------------------- 1 file changed, 53 insertions(+), 100 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index ada798d..e62b011 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -44,6 +44,10 @@ class Emitter(GObject.GObject): def target_time_changed(self, time: timedelta) -> None: pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) + def target_time_reset(self, time: timedelta) -> None: + pass + @GObject.Signal(flags=GObject.SignalFlags.RUN_LAST, arg_types=(object,)) def remaining_time_changed(self, time: datetime) -> None: pass @@ -87,38 +91,24 @@ class ServerClock: self.start_time = datetime.combine(datetime.today(), time(hour, minute)) self.cur_time = datetime.combine(datetime.today(), time(hour, minute)) - # NOTE: at highest possible accel interval (64 * 64), real time is 0.876 seconds - if self.is_night(): - GLib.timeout_add(500, self.increment_night) - else: - GLib.timeout_add(500, self.increment_day) + # NOTE: highest theoretical accel interval is 24 * 64 at night + GLib.timeout_add(400, self._increment_with_offset) @staticmethod def split_time(server_time: Time) -> tuple[int, int]: split = server_time.time.split(":") return int(split[0]), int(split[1]) - def _increment_with_offset(self, offset: float) -> None: + def _increment_with_offset(self) -> None: + factor = self.get_accel_factor(self.cur_time) elapsed = self.local_clock.get_elapsed_time() - virtual_elapsed = elapsed * offset + virtual_elapsed = elapsed * factor self.cur_time = self.start_time + virtual_elapsed self.emitter.emit("server_time_incremented", self.cur_time) - - def increment_day(self) -> bool: - if self.is_night(): - GLib.timeout_add(500, self.increment_night) - return False - offset = self.day_accel - self._increment_with_offset(offset) return True - def increment_night(self) -> bool: - if not self.is_night(): - GLib.timeout_add(500, self.increment_day) - return False - offset = self.night_accel * self.day_accel - self._increment_with_offset(offset) - return True + def increment(self) -> bool: + self._increment_with_offset def is_night(self) -> bool: return ( @@ -126,92 +116,47 @@ class ServerClock: or self.cur_time.time() <= NIGHT_END_TIME ) + def get_accel_factor(self, dt: datetime) -> bool: + return ( + self.day_accel + if DAY_START_TIME <= dt.time() < DAY_END_TIME + else (self.night_accel * self.day_accel) + ) + def get_values(self) -> tuple[int, int]: return self.cur_time.hour, self.cur_time.minute def get_time(self) -> time: return self.cur_time.time() - def get_cycle_remainder(self, accel: bool = True) -> float: - # NOTE: calc from cycle start time to current point - if self.is_night(): - cutoff = DAY_START_TIME - offset = self.day_accel * self.night_accel - else: - cutoff = NIGHT_START_TIME - offset = self.day_accel - start = datetime.combine(self.cur_time, cutoff) + def calc_delta(self, end_time: datetime) -> timedelta: + total = 0.0 + current_time = self.cur_time - if accel is False: - offset = 1 - return (start - self.cur_time).total_seconds() / offset + if current_time > end_time: + end_time += timedelta(days=1) + while current_time < end_time: + factor = self.get_accel_factor(current_time) - def get_full_cycle(self, end_time: time, start_time: time) -> float: - end = datetime.combine(self.cur_time, end_time) - start = datetime.combine(self.cur_time, start_time) - if start > end: - end += timedelta(days=1) - return (end - start).total_seconds() + edge = ( + DAY_END_TIME if current_time.time() < DAY_END_TIME else DAY_START_TIME + ) - def get_day_cycle(self) -> float: - return self.get_full_cycle(DAY_END_TIME, DAY_START_TIME) + next_edge = datetime.combine(current_time.date(), edge) - def get_night_cycle(self) -> float: - return self.get_full_cycle(NIGHT_END_TIME, NIGHT_START_TIME) - - def get_delta(self, picker_time: time, start_time: time) -> float: - start = datetime.combine(self.cur_time, start_time) - picker_date = datetime.combine(self.cur_time, picker_time) - if start > picker_date: - picker_date += timedelta(days=1) - delta = (picker_date - start).total_seconds() - return delta - - def get_next_cycle(self) -> float: - if self.is_night(): - return self.get_day_cycle() / self.day_accel - else: - return self.get_night_cycle() / (self.day_accel * self.night_accel) - - def get_remaining_delta(self, picker_time: time, is_night: bool) -> float: - if is_night: - start_time = NIGHT_START_TIME - offset = self.day_accel * self.night_accel - else: - start_time = DAY_START_TIME - offset = self.day_accel - return self.get_delta(picker_time, start_time) / offset - - def calc_delta(self, picker_time: time, is_night: bool) -> timedelta: - # NOTE: time spans two full cycles - if self.get_time() > picker_time: - to_end = self.get_cycle_remainder() - next_cycle = self.get_next_cycle() - delta = self.get_remaining_delta(picker_time, is_night) - total = to_end + next_cycle + delta - else: - picker_date = datetime.combine(self.cur_time, picker_time) - # NOTE: strip sub-minute values - new = self.cur_time.replace(second=0, microsecond=0) - delta = (picker_date - new).total_seconds() - remainder_till_cycle_end = self.get_cycle_remainder(accel=False) - if delta <= remainder_till_cycle_end: - if self.is_night(): - total = delta / (self.day_accel * self.night_accel) - else: - total = delta / self.day_accel - else: - total = self.get_remaining_delta(picker_time, is_night) - - total = round(total) - td = timedelta(seconds=total) - return td + if next_edge <= current_time: + next_edge += timedelta(days=1) + chunk_end = min(end_time, next_edge) + total += (chunk_end - current_time).total_seconds() / factor + current_time = chunk_end + return timedelta(seconds=total) class RemainderClock: def __init__(self, emitter: Emitter) -> None: self.emitter = emitter self.emitter.connect("target_time_changed", self._on_target_time_changed) + self.emitter.connect("target_time_reset", self._on_target_time_reset) self.emitter.connect("local_time_incremented", self._on_local_time_incremented) self.reset_time(True) @@ -238,8 +183,12 @@ class RemainderClock: self.previous = elapsed self.emitter.emit("remaining_time_changed", self.time) + def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None: + self.reset_time(True) + self.emitter.emit("remaining_time_changed", self.time) + def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: - self.reset_time() + self.reset_time(True) self.time += time self.emitter.emit("remaining_time_changed", self.time) @@ -323,11 +272,14 @@ class TimePicker(Gtk.Box): def get_time(self) -> time: d = datetime.now() - return time( - hour=self.get_hour(), - minute=self.get_minute(), - second=d.second, - microsecond=d.microsecond, + return datetime.combine( + d, + time( + hour=self.get_hour(), + minute=self.get_minute(), + second=d.second, + microsecond=d.microsecond, + ), ) def set_time(self, hour: int, minute: int) -> None: @@ -448,11 +400,12 @@ class TimePickerFrame(Gtk.Frame): def _on_calculate_button_clicked(self, button: Gtk.Button) -> None: if (self.picker.get_values()) == (self.server_time.get_values()): total_time = timedelta(seconds=0) + self.emitter.emit("target_time_reset", total_time) else: picker_time = self.picker.get_time() - is_night = self.picker.is_night() - total_time = self.server_time.calc_delta(picker_time, is_night) - self.emitter.emit("target_time_changed", total_time) + total_time = self.server_time.calc_delta(picker_time) + print(total_time) + self.emitter.emit("target_time_changed", total_time) class ServerTimeCalculator(Gtk.ScrolledWindow): From d4bf9f71f6351524bf6ceed9df25c5d3c65c753b Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:28:12 +0900 Subject: [PATCH 16/29] chore: clear typehinting errors --- dzgui/views/dialogs/calc.py | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index e62b011..05ab31c 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -99,7 +99,7 @@ class ServerClock: split = server_time.time.split(":") return int(split[0]), int(split[1]) - def _increment_with_offset(self) -> None: + def _increment_with_offset(self) -> Literal[True]: factor = self.get_accel_factor(self.cur_time) elapsed = self.local_clock.get_elapsed_time() virtual_elapsed = elapsed * factor @@ -107,16 +107,7 @@ class ServerClock: self.emitter.emit("server_time_incremented", self.cur_time) return True - def increment(self) -> bool: - self._increment_with_offset - - def is_night(self) -> bool: - return ( - self.cur_time.time() >= NIGHT_START_TIME - or self.cur_time.time() <= NIGHT_END_TIME - ) - - def get_accel_factor(self, dt: datetime) -> bool: + def get_accel_factor(self, dt: datetime) -> float: return ( self.day_accel if DAY_START_TIME <= dt.time() < DAY_END_TIME @@ -270,7 +261,7 @@ class TimePicker(Gtk.Box): def get_minute(self) -> int: return int(self.minute_spin.get_adjustment().get_value()) - def get_time(self) -> time: + def get_time(self) -> datetime: d = datetime.now() return datetime.combine( d, @@ -286,10 +277,6 @@ class TimePicker(Gtk.Box): self.hour_spin.get_adjustment().set_value(hour) self.minute_spin.get_adjustment().set_value(minute) - def is_night(self) -> bool: - t = self.get_time() - return t >= NIGHT_START_TIME or t <= NIGHT_END_TIME - class AdjustedTimeFrame(Gtk.Frame): def __init__(self, emitter: Emitter) -> None: @@ -404,7 +391,6 @@ class TimePickerFrame(Gtk.Frame): else: picker_time = self.picker.get_time() total_time = self.server_time.calc_delta(picker_time) - print(total_time) self.emitter.emit("target_time_changed", total_time) From e653c619550951b5aeddedc4e62ef3ea301ace9f Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:43:27 +0900 Subject: [PATCH 17/29] chore: remove debug mark --- tests/test_time.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_time.py b/tests/test_time.py index 4bc8fcb..6ec869f 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -3,8 +3,6 @@ import re from dzgui.model.proxy_model import DAY_REG, NIGHT_REG import pytest -pytestmark = pytest.mark.FOO - def iterate(h: str, r: str) -> None: for m in range(60): time = f"{h:02}:{m:02}" From 7c0981981ff4abb4e5dd07fec8b6ed514da05947 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:43:48 +0900 Subject: [PATCH 18/29] chore: drop unused import --- tests/test_time.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_time.py b/tests/test_time.py index 6ec869f..c9096b6 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -1,7 +1,6 @@ import re from dzgui.model.proxy_model import DAY_REG, NIGHT_REG -import pytest def iterate(h: str, r: str) -> None: for m in range(60): From 84c6a3868b07f08cab9afba4295f34d98f98c89c Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:44:32 +0900 Subject: [PATCH 19/29] change: normalize time boundaries --- dzgui/views/dialogs/calc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 05ab31c..8a36397 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -11,9 +11,9 @@ gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402 DAY_START_TIME = time(hour=6, minute=0, second=0) -DAY_END_TIME = time(hour=17, minute=59, second=59) +DAY_END_TIME = time(hour=18, minute=0, second=0) NIGHT_START_TIME = time(hour=18, minute=0, second=0) -NIGHT_END_TIME = time(hour=5, minute=59, second=59) +NIGHT_END_TIME = time(hour=6, minute=0, second=0) EXTENDED_TIME_FORMAT = "%H:%M:%S" From 0e58cf514e3476e431d1b0bf25737cdc7cef3e25 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:45:07 +0900 Subject: [PATCH 20/29] fix: date traversal logic --- dzgui/views/dialogs/calc.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 8a36397..e399c16 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -117,6 +117,9 @@ class ServerClock: def get_values(self) -> tuple[int, int]: return self.cur_time.hour, self.cur_time.minute + def get_datetime(self) -> datetime: + return self.cur_time + def get_time(self) -> time: return self.cur_time.time() @@ -126,18 +129,22 @@ class ServerClock: if current_time > end_time: end_time += timedelta(days=1) + while current_time < end_time: factor = self.get_accel_factor(current_time) + day_start = datetime.combine(current_time.date(), DAY_START_TIME) + day_end = datetime.combine(current_time.date(), DAY_END_TIME) - edge = ( - DAY_END_TIME if current_time.time() < DAY_END_TIME else DAY_START_TIME - ) + if current_time.time() < DAY_START_TIME: + edge = day_start + elif current_time.time() < DAY_END_TIME: + edge = day_end + else: + edge = datetime.combine( + current_time.date() + timedelta(days=1), DAY_START_TIME + ) - next_edge = datetime.combine(current_time.date(), edge) - - if next_edge <= current_time: - next_edge += timedelta(days=1) - chunk_end = min(end_time, next_edge) + chunk_end = min(end_time, edge) total += (chunk_end - current_time).total_seconds() / factor current_time = chunk_end return timedelta(seconds=total) @@ -268,8 +275,8 @@ class TimePicker(Gtk.Box): time( hour=self.get_hour(), minute=self.get_minute(), - second=d.second, - microsecond=d.microsecond, + second=0, + microsecond=0, ), ) From f07a2dea5f3afb90a9b5733325434d1f37cb7c3e Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:45:30 +0900 Subject: [PATCH 21/29] chore: add calculator reference test --- pyproject.toml | 1 + tests/test_server_time.py | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/test_server_time.py diff --git a/pyproject.toml b/pyproject.toml index cd00f7e..6c71519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,7 @@ docs = [ ] test = [ "black==25.1.0", + "hypothesis==6.167.1", "pre-commit==4.4.0", "pytest==8.4.2", "pytest-cov==7.0.0", diff --git a/tests/test_server_time.py b/tests/test_server_time.py new file mode 100644 index 0000000..22c0b8f --- /dev/null +++ b/tests/test_server_time.py @@ -0,0 +1,110 @@ +import pytest + +from hypothesis import given, strategies as st +from datetime import datetime, timedelta + +from dzgui.views.dialogs.calc import ( + LocalClock, + ServerClock, + Time, + TimePicker, + DAY_START_TIME, + DAY_END_TIME, +) + +pytestmark = pytest.mark.FOO + + +class DummyEmitter: + pass + + +@pytest.fixture(params=[("05:00", 10.0), ("15:30", 5.0)]) +def accel_server_clock(request) -> None: + clock_time, expect = request.param + cur_time = Time(clock_time, 5.0, 2.0) + emitter = DummyEmitter() + local_clock = LocalClock(emitter) + server_clock = ServerClock(cur_time, emitter, local_clock) + return expect, server_clock + + +def test_accel_factor(accel_server_clock) -> None: + """Static test checking times at known boundaries""" + expect, clock = accel_server_clock + assert clock.get_accel_factor(clock.cur_time) == expect + + +def reference_calc(start, end, day_factor, night_factor): + """ + Reference implementation to test validity of UI-side calculator + Aggregates total seconds and applies accel factors after + """ + day_start = datetime.combine(start.date(), DAY_START_TIME) + day_end = datetime.combine(start.date(), DAY_END_TIME) + night_factor = day_factor * night_factor + + if end < start: + end += timedelta(days=1) + + day_max = max(start, day_start) + day_min = min(end, day_end) + + next_day_start = datetime.combine(end.date(), DAY_START_TIME) + next_day_end = datetime.combine(end.date(), DAY_END_TIME) + + range1 = (day_end - day_max).total_seconds() + range2 = (min(end, next_day_end) - next_day_start).total_seconds() + + if end.date() > start.date(): + day_seconds = max(0, range1) + max(0, range2) + else: + day_seconds = max(0, (day_min - day_max).total_seconds()) + + total_seconds = (end - start).total_seconds() + night_seconds = (total_seconds - day_seconds) / night_factor + day_seconds = day_seconds / day_factor + total = night_seconds + day_seconds + + return timedelta(seconds=total) + + +day_accel = st.floats( + min_value=1.0, max_value=24.0, allow_nan=False, allow_infinity=False +) +night_accel = st.floats( + min_value=1.0, max_value=64.0, allow_nan=False, allow_infinity=False +) + + +@st.composite +def time_ranges(draw): + start_time = draw(st.times()) + deltas = st.timedeltas(min_value=timedelta(0), max_value=timedelta(days=1)) + duration = draw(deltas) + + start = datetime.combine(datetime.now(), start_time) + end = start + duration + return start, end + + +@given(span=time_ranges(), day_factor=day_accel, night_factor=night_accel) +def test_time_aggregation(span, day_factor, night_factor) -> None: + start, end = span + + strtime = start.strftime("%H:%M") + t = Time(strtime, day_factor, night_factor) + emitter = DummyEmitter() + local_clock = LocalClock(emitter) + server_clock = ServerClock(t, emitter, local_clock) + h = end.time().hour + m = end.time().minute + picker = TimePicker(h, m) + + # NOTE: truncates microseconds to mimic widget HH:MM selection behavior + start = server_clock.get_datetime() + end = picker.get_time() + actual = server_clock.calc_delta(end) + expect = reference_calc(start, end, day_factor, night_factor) + + assert actual == expect From 18192a46672788e9842393c6ae6e4ecc80b14ae4 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:46:22 +0900 Subject: [PATCH 22/29] chore: remove debug mark --- tests/test_server_time.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_server_time.py b/tests/test_server_time.py index 22c0b8f..a78d453 100644 --- a/tests/test_server_time.py +++ b/tests/test_server_time.py @@ -12,8 +12,6 @@ from dzgui.views.dialogs.calc import ( DAY_END_TIME, ) -pytestmark = pytest.mark.FOO - class DummyEmitter: pass From 95380681f991afdbcafd0209b206709462dc21ee Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:51:26 +0900 Subject: [PATCH 23/29] chore: update test to new Details dataclass format --- tests/test_html_escaping.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_html_escaping.py b/tests/test_html_escaping.py index f5c0805..471bb1a 100644 --- a/tests/test_html_escaping.py +++ b/tests/test_html_escaping.py @@ -22,9 +22,14 @@ class MockController: def test_html_escaping() -> None: - text = " Game & mods" - details = Details([["0", "1"]], text, True) + name = "Test server" + desc = " Game & mods" + gametime = "05:30" + day_accel = 5.0 + night_accel = 1.0 + details = Details([["0", "1"]], name, desc, gametime, day_accel, night_accel) controller = MockController() dialog = ServerDetailsDialog(controller, details) + dialog.destroy() - assert dialog.description.get_text() == text + assert dialog.description.get_text() == desc From e773a4909b4c294fb36630256c5113e11180a3a3 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:02:23 +0900 Subject: [PATCH 24/29] fix: restore adjusted time to current time on reset --- dzgui/views/dialogs/calc.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index e399c16..1086190 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -184,6 +184,8 @@ class RemainderClock: def _on_target_time_reset(self, emitter: Emitter, time: timedelta) -> None: self.reset_time(True) self.emitter.emit("remaining_time_changed", self.time) + # NOTE: restore adjusted time to current time + self.emitter.emit("adjusted_local_time_changed", datetime.now()) def _on_target_time_changed(self, emitter: Emitter, time: timedelta) -> None: self.reset_time(True) From 3b15bf58e76888e47ac631fc370003213b53ddbb Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:28:04 +0900 Subject: [PATCH 25/29] feat: add time of day icon --- dzgui/const/constants.py | 2 ++ dzgui/views/components/icon.py | 3 +++ dzgui/views/dialogs/calc.py | 25 ++++++++++++++++++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 6089941..c0ff071 100644 --- a/dzgui/const/constants.py +++ b/dzgui/const/constants.py @@ -55,6 +55,8 @@ VIEW_CONCEAL = "view-conceal-symbolic" VIEW_REVEAL = "view-reveal-symbolic" WARNING = "dialog-warning-symbolic" WEB_BROWSER = "web-browser-symbolic" +DAY_ICON = "weather-clear-symbolic" +NIGHT_ICON = "weather-clear-night-symbolic" SEPARATOR = "SEPARATOR" diff --git a/dzgui/views/components/icon.py b/dzgui/views/components/icon.py index a8abd11..a0f8b1e 100644 --- a/dzgui/views/components/icon.py +++ b/dzgui/views/components/icon.py @@ -9,3 +9,6 @@ class Icon(Gtk.Image): super().__init__( icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin ) + + def set_from_icon_name(self, name: str) -> None: + super().set_from_icon_name(name, Gtk.IconSize.BUTTON) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 1086190..5278c92 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -3,9 +3,11 @@ from dataclasses import dataclass from datetime import datetime, time, timedelta from typing import Literal -from dzgui.views.components.labels import BoldLabel +from dzgui.const.constants import DAY_ICON, NIGHT_ICON from dzgui.strings import calc -from dzgui.views.components.box import VBox +from dzgui.views.components.icon import Icon +from dzgui.views.components.labels import BoldLabel +from dzgui.views.components.box import VBox, HBox gi.require_version("Gtk", "3.0") from gi.repository import Gtk, GLib, GObject # type: ignore # noqa E402 @@ -99,6 +101,11 @@ class ServerClock: split = server_time.time.split(":") return int(split[0]), int(split[1]) + def is_day(self) -> bool: + if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME: + return True + return False + def _increment_with_offset(self) -> Literal[True]: factor = self.get_accel_factor(self.cur_time) elapsed = self.local_clock.get_elapsed_time() @@ -365,6 +372,13 @@ class TimePickerFrame(Gtk.Frame): h1 = BoldLabel(calc.heading_server_time) h2 = BoldLabel(calc.heading_target_time) + self.icon_indicator = Icon(DAY_ICON) + self.update_server_icon() + + server_time_box = HBox(10) + server_time_box.set_halign(Gtk.Align.CENTER) + server_time_box.extend([self.server_timelabel, self.icon_indicator]) + flowbox = Gtk.FlowBox( row_spacing=10, column_spacing=50, @@ -378,14 +392,19 @@ class TimePickerFrame(Gtk.Frame): flowbox.add(h1) flowbox.add(h2) flowbox.add(self.local_timelabel) - flowbox.add(self.server_timelabel) + flowbox.add(server_time_box) flowbox.add(self.picker) self.add(flowbox) + def update_server_icon(self) -> None: + icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON + self.icon_indicator.set_from_icon_name(icon) + def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None: t = time_now.time().strftime("%H:%M") self.server_timelabel.set_text(t) + self.update_server_icon() def _on_local_clock_increment( self, emitter: Emitter, time_now: datetime, elapsed: timedelta From 2629fe254eefdd44667e477604009c880708ac8d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:29:04 +0900 Subject: [PATCH 26/29] fix: superclass type collision --- dzgui/views/components/icon.py | 2 +- dzgui/views/dialogs/calc.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dzgui/views/components/icon.py b/dzgui/views/components/icon.py index a0f8b1e..2cb222d 100644 --- a/dzgui/views/components/icon.py +++ b/dzgui/views/components/icon.py @@ -10,5 +10,5 @@ class Icon(Gtk.Image): icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin ) - def set_from_icon_name(self, name: str) -> None: + def set_icon_name(self, name: str) -> None: super().set_from_icon_name(name, Gtk.IconSize.BUTTON) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 5278c92..b24043f 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -399,7 +399,7 @@ class TimePickerFrame(Gtk.Frame): def update_server_icon(self) -> None: icon = DAY_ICON if self.server_time.is_day() else NIGHT_ICON - self.icon_indicator.set_from_icon_name(icon) + self.icon_indicator.set_icon_name(icon) def _on_server_clock_increment(self, emitter: Emitter, time_now: datetime) -> None: t = time_now.time().strftime("%H:%M") From e8ccdd7cbba26f2e12f80bb2a5ee49e00560c605 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:56:24 +0900 Subject: [PATCH 27/29] fix: local clock drives virtual clock --- dzgui/views/dialogs/calc.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index b24043f..68eea44 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -93,8 +93,8 @@ class ServerClock: self.start_time = datetime.combine(datetime.today(), time(hour, minute)) self.cur_time = datetime.combine(datetime.today(), time(hour, minute)) - # NOTE: highest theoretical accel interval is 24 * 64 at night - GLib.timeout_add(400, self._increment_with_offset) + self._last_elapsed = self.local_clock.get_elapsed_time() + self.emitter.connect("local_time_incremented", self._on_local_time_incremented) @staticmethod def split_time(server_time: Time) -> tuple[int, int]: @@ -106,11 +106,17 @@ class ServerClock: return True return False - def _increment_with_offset(self) -> Literal[True]: + def _on_local_time_incremented( + # NOTE: highest theoretical accel interval is 24 * 64 at night + self, + emitter: Emitter, + time: datetime, + elapsed: timedelta, + ) -> Literal[True]: + delta = elapsed - self._last_elapsed + self._last_elapsed = elapsed factor = self.get_accel_factor(self.cur_time) - elapsed = self.local_clock.get_elapsed_time() - virtual_elapsed = elapsed * factor - self.cur_time = self.start_time + virtual_elapsed + self.cur_time += delta * factor self.emitter.emit("server_time_incremented", self.cur_time) return True From cee1e3b8af3854604c75fcfd829566f0bcb4954d Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:58:39 +0900 Subject: [PATCH 28/29] chore: move comment --- dzgui/views/dialogs/calc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 68eea44..237e41a 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -63,6 +63,7 @@ class LocalClock: def __init__(self, emitter: Emitter) -> None: self.emitter = emitter self.start_time = datetime.now() + # NOTE: highest theoretical accel interval is 24 * 64 GLib.timeout_add(500, self.check_time) def get_time(self) -> str: @@ -107,7 +108,6 @@ class ServerClock: return False def _on_local_time_incremented( - # NOTE: highest theoretical accel interval is 24 * 64 at night self, emitter: Emitter, time: datetime, From c15b77d470255ddeeda4cf4665b76371fa0b7287 Mon Sep 17 00:00:00 2001 From: aclist <92275929+aclist@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:07:41 +0900 Subject: [PATCH 29/29] chore: add comments regarding time interpolation --- dzgui/views/dialogs/calc.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py index 237e41a..979cb0a 100644 --- a/dzgui/views/dialogs/calc.py +++ b/dzgui/views/dialogs/calc.py @@ -284,6 +284,13 @@ class TimePicker(Gtk.Box): return int(self.minute_spin.get_adjustment().get_value()) def get_time(self) -> datetime: + """ + NOTE: interpolating seconds/microseconds from the current time + may prevent abrupt visual changes when setting a new TimePicker time + while a clock tick is updating the local remainder label. + However, this can introduce false negatives when running tests due to microsecond drift. + Ideally, tests should be decoupled from this logic. + """ d = datetime.now() return datetime.combine( d,