This commit is contained in:
aclist authored and GitHub committed 2026-09-03 19:00:44 +00:00
commit 8a94cd090f
19 files changed
+650 -80

No files matched your search

+20 -25
View File
@@ -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:
+1
View File
@@ -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"
+7 -4
View File
@@ -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
+4
View File
@@ -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
}
+1 -1
View File
@@ -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
)
+2 -2
View File
@@ -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",
+2 -2
View File
@@ -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:
+3 -1
View File
@@ -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()
+7
View File
@@ -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."
+5
View File
@@ -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"
-2
View File
@@ -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
+1
View File
@@ -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)
-6
View File
@@ -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:
+477
View File
@@ -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)
+8
View File
@@ -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:
+46 -25
View File
@@ -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,
+18 -11
View File
@@ -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()
+45
View File
@@ -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
+3 -1
View File
@@ -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)