diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index e921d8b..6347230 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -54,19 +54,16 @@ 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) +@dataclass(slots=True, frozen=True) class Record: - # TODO: investigate whether this enum is still being used; - # can this be frozen? - """ - The gameport field is manipulated by the RowType.CONN_BY_IP method - """ - ip: str gameport: int qport: int @@ -283,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 @@ -385,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..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/controllers/mc.py b/dzgui/controllers/mc.py index 8f35fc5..200cc1b 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -7,11 +7,12 @@ import dzgui.util._json as JSON # noqa from dzgui.const.enum import ( - FilterMode, - Preferences, - NotebookPage, ButtonType, ContextMenu, + FilterMode, + NotebookPage, + Preferences, + ServerTab, ) from dzgui.config.userprefs import UserPrefs @@ -37,7 +38,6 @@ logger = logging.getLogger(APP_NAME) if TYPE_CHECKING: from dzgui.api.servers import Record - from dzgui.const.enum import ServerTab from dzgui.managers.connection import Prerequisites from dzgui.managers.filter import FilterManager from dzgui.util.dist import Haversine @@ -498,6 +498,9 @@ class Controller(GObject.GObject): def set_active_keyword(self, keyword: str) -> None: self.get_filter_man().set_active_keyword(keyword) + ServerModelManager(self, self.get_active_treeview()).refilter( + FilterMode.TOGGLE_ON + ) def get_modtreeview(self) -> "ModTreeView": return self.mediator.modtreeview 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/model/servers.py b/dzgui/model/servers.py index a429ffb..e3ef6c9 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -21,7 +21,7 @@ from dzgui.views.dialogs.generic import ExceptionDialog import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk # noqa E402 +from gi.repository import Gtk, GLib # noqa E402 if TYPE_CHECKING: from dzgui.api.servers import A2SInfo, Record @@ -390,6 +390,8 @@ class ServerModelManager: def _cleanup_on_success(self) -> None: proxy = self.proxy_man.get_proxy_model() self.tv.set_model(proxy) + if self.tv.enum == ServerTab.BROWSER: + GLib.idle_add(self.tv.wipe_cache) if self.preserve_on_fail is True: self.proxy_man.wipe_cache() 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/filter_panel.py b/dzgui/views/components/filter_panel.py index 35a6ea4..b9f29a9 100644 --- a/dzgui/views/components/filter_panel.py +++ b/dzgui/views/components/filter_panel.py @@ -9,8 +9,6 @@ from dzgui.const.constants import ( NO_PADDING, SEARCH_ICON, ) -from dzgui.const.enum import FilterMode -from dzgui.model.servers import ServerModelManager from dzgui.util.format import format_exception from dzgui.util.strings import all_maps from dzgui.views.components.maps_combo import MapsCombo @@ -148,10 +146,6 @@ class KeywordEntry(Gtk.Entry): self.controller.set_active_keyword(keyword) logger.info(f"User filtered by keyword '{keyword}'") - ServerModelManager( - self.controller, self.controller.get_active_treeview() - ).refilter(FilterMode.TOGGLE_ON) - class FilterPanel(Gtk.Box): def __init__(self, controller: "Controller") -> None: diff --git a/dzgui/views/dialogs/calc.py b/dzgui/views/dialogs/calc.py new file mode 100644 index 0000000..ada798d --- /dev/null +++ b/dzgui/views/dialogs/calc.py @@ -0,0 +1,477 @@ +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) + + 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: + 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 RemainderClock: + def __init__(self, emitter: Emitter) -> None: + 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(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_changed(self, emitter: Emitter, time: timedelta) -> None: + self.reset_time() + 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) -> time: + 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) + 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) diff --git a/dzgui/views/dialogs/generic.py b/dzgui/views/dialogs/generic.py index b504955..cff1e2a 100644 --- a/dzgui/views/dialogs/generic.py +++ b/dzgui/views/dialogs/generic.py @@ -309,6 +309,14 @@ class ExceptionDialog(TextBufferDialog): content.set_spacing(0) content.add(scrollable) + self.connect("map", self._on_map) + + def _on_map(self, dialog: Self) -> None: + button = self.get_widget_for_response(Gtk.ResponseType.OK) + if button is None: + return + button.grab_focus() + def _on_page_changed( self, notebook: Gtk.Notebook, child: Gtk.Box | Gtk.ScrolledWindow, index: int ) -> None: 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/dzgui/views/trees/tree_servers.py b/dzgui/views/trees/tree_servers.py index 2ec2f32..f910c6f 100644 --- a/dzgui/views/trees/tree_servers.py +++ b/dzgui/views/trees/tree_servers.py @@ -66,7 +66,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore self.handler_id: int self.queue: Queue = Queue() - self.seen_cache: list[str] = [] + self.seen_cache: list[Record] = [] prefs = self.controller.get_prefs() columns = prefs.paths.columns @@ -341,16 +341,24 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore control = self.proxy_man.get_control() return model, control - @staticmethod def ping_server( + self, model: "FastInsertListStore", _iter: Gtk.TreeIter, - ip: str, - qport: int, - ping_column: int, + record: Record, ) -> None: - _ping = ping(ip, qport) - GLib.idle_add(lambda: model.set(_iter, ping_column, _ping)) + _ping = ping(record.ip, record.qport) + GLib.idle_add(self._redraw, model, _iter, _ping) + + def _redraw( + self, model: "FastInsertListStore", _iter: Gtk.TreeIter, _ping: int + ) -> None: + ping_column = 9 + model.set(_iter, ping_column, _ping) + self.queue_draw() + + def wipe_cache(self) -> None: + self.seen_cache = [] def _get_ping( self, @@ -363,13 +371,12 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore addr_column = 7 qport_column = 8 - ping_column = 9 addr = model.get_value(_iter, addr_column).split(":") ip = addr[0] - gameport = addr[1] + gameport = int(addr[1]) qport = model.get_value(_iter, qport_column) - record = f"{addr}:{gameport}:{qport}" + record = Record(ip, gameport, qport) if record in self.seen_cache: return @@ -378,7 +385,7 @@ class ServerTreeView(ContextMixin, TreeView): # type: ignore thread = threading.Thread( daemon=True, target=self.ping_server, - args=(model, _iter, ip, qport, ping_column), + args=(model, _iter, record), ) thread.start() 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_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)