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: diff --git a/dzgui/const/constants.py b/dzgui/const/constants.py index 0ce8702..c0ff071 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" @@ -54,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/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 } 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 ) 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/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/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 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) diff --git a/dzgui/views/components/icon.py b/dzgui/views/components/icon.py index a8abd11..2cb222d 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_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 new file mode 100644 index 0000000..979cb0a --- /dev/null +++ b/dzgui/views/dialogs/calc.py @@ -0,0 +1,457 @@ +import gi +from dataclasses import dataclass +from datetime import datetime, time, timedelta +from typing import Literal + +from dzgui.const.constants import DAY_ICON, NIGHT_ICON +from dzgui.strings import calc +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=18, minute=0, second=0) +NIGHT_START_TIME = time(hour=18, minute=0, second=0) +NIGHT_END_TIME = time(hour=6, minute=0, second=0) +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 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 + + @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() + # NOTE: highest theoretical accel interval is 24 * 64 + GLib.timeout_add(500, self.check_time) + + 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)) + + 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 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) + return True + + def get_accel_factor(self, dt: datetime) -> float: + 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_datetime(self) -> datetime: + return self.cur_time + + def get_time(self) -> time: + return self.cur_time.time() + + def calc_delta(self, end_time: datetime) -> timedelta: + total = 0.0 + current_time = self.cur_time + + 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) + + 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 + ) + + 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) + + self.previous = timedelta(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 + ) -> 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_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) + 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: + def __init__(self, emitter: Emitter) -> None: + 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: + 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) -> 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, + 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) + + +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) + + 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, + 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(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 + ) -> 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) + self.emitter.emit("target_time_reset", total_time) + else: + picker_time = self.picker.get_time() + total_time = self.server_time.calc_delta(picker_time) + 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) diff --git a/dzgui/views/dialogs/servers.py b/dzgui/views/dialogs/servers.py index d957158..6166aee 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,9 @@ 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 +101,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 +110,32 @@ 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, 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_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 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 diff --git a/tests/test_server_time.py b/tests/test_server_time.py new file mode 100644 index 0000000..a78d453 --- /dev/null +++ b/tests/test_server_time.py @@ -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 diff --git a/tests/test_time.py b/tests/test_time.py index 210068a..c9096b6 100644 --- a/tests/test_time.py +++ b/tests/test_time.py @@ -2,7 +2,6 @@ import re from dzgui.model.proxy_model import DAY_REG, NIGHT_REG - def iterate(h: str, r: str) -> None: for m in range(60): time = f"{h:02}:{m:02}" @@ -18,6 +17,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)