Compare commits

...
Author SHA1 Message Date
aclist c15b77d470 chore: add comments regarding time interpolation
Mirror to Codeberg / mirror-to-codeberg (push) Has been cancelled
2026-09-08 05:07:41 +09:00
aclist cee1e3b8af chore: move comment 2026-09-08 02:58:39 +09:00
aclist e8ccdd7cbb fix: local clock drives virtual clock 2026-09-08 02:56:24 +09:00
aclist 2629fe254e fix: superclass type collision 2026-09-08 02:29:04 +09:00
aclist 3b15bf58e7 feat: add time of day icon 2026-09-08 02:28:04 +09:00
aclist e773a4909b fix: restore adjusted time to current time on reset 2026-09-08 01:02:23 +09:00
aclist 95380681f9 chore: update test to new Details dataclass format 2026-09-08 00:51:26 +09:00
aclist 18192a4667 chore: remove debug mark 2026-09-08 00:46:22 +09:00
aclist f07a2dea5f chore: add calculator reference test 2026-09-08 00:45:30 +09:00
aclist 0e58cf514e fix: date traversal logic 2026-09-08 00:45:07 +09:00
aclist 84c6a3868b change: normalize time boundaries 2026-09-08 00:44:32 +09:00
aclist 7c0981981f chore: drop unused import 2026-09-08 00:43:48 +09:00
aclist e653c61955 chore: remove debug mark 2026-09-08 00:43:27 +09:00
aclist d4bf9f71f6 chore: clear typehinting errors 2026-09-04 19:28:12 +09:00
aclist cb5c233f45 change: simplify calculation logic 2026-09-04 19:25:37 +09:00
7 changed files with 217 additions and 121 deletions

No files matched your search

+2
View File
@@ -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"
+3
View File
@@ -9,3 +9,6 @@ class Icon(Gtk.Image):
super().__init__(
icon_name=name, icon_size=Gtk.IconSize.BUTTON, margin_start=l_margin
)
def set_icon_name(self, name: str) -> None:
super().set_from_icon_name(name, Gtk.IconSize.BUTTON)
+95 -115
View File
@@ -3,17 +3,19 @@ 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
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"
@@ -44,6 +46,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
@@ -57,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:
@@ -87,131 +94,80 @@ 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)
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]:
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
def is_day(self) -> bool:
if DAY_START_TIME <= self.cur_time.time() <= DAY_END_TIME:
return True
return False
def _on_local_time_incremented(
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)
self.cur_time += delta * factor
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:
def get_accel_factor(self, dt: datetime) -> float:
return (
self.cur_time.time() >= NIGHT_START_TIME
or self.cur_time.time() <= NIGHT_END_TIME
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_datetime(self) -> datetime:
return self.cur_time
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)
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()
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)
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:
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
if current_time.time() < DAY_START_TIME:
edge = day_start
elif current_time.time() < DAY_END_TIME:
edge = day_end
else:
total = self.get_remaining_delta(picker_time, is_night)
edge = datetime.combine(
current_time.date() + timedelta(days=1), DAY_START_TIME
)
total = round(total)
td = timedelta(seconds=total)
return td
chunk_end = min(end_time, 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 +194,14 @@ 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)
# 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()
self.reset_time(True)
self.time += time
self.emitter.emit("remaining_time_changed", self.time)
@@ -321,23 +283,29 @@ 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:
"""
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 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=0,
microsecond=0,
),
)
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:
@@ -417,6 +385,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,
@@ -430,14 +405,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_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
@@ -448,11 +428,11 @@ 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)
self.emitter.emit("target_time_changed", total_time)
class ServerTimeCalculator(Gtk.ScrolledWindow):
+1
View File
@@ -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",
+8 -3
View File
@@ -22,9 +22,14 @@ class MockController:
def test_html_escaping() -> None:
text = "<Server description> Game & mods"
details = Details([["0", "1"]], text, True)
name = "Test server"
desc = "<Server description> 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
+108
View File
@@ -0,0 +1,108 @@
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,
)
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
-3
View File
@@ -1,9 +1,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):