diff --git a/nix/package/default.nix b/nix/package/default.nix index fc987eca7f..f3f65b815b 100644 --- a/nix/package/default.nix +++ b/nix/package/default.nix @@ -54,7 +54,6 @@ python3Packages.buildPythonApplication { pillow-heif py7zr pydantic - pydub pygments pyside6 rarfile diff --git a/pyproject.toml b/pyproject.toml index 3715d9fa80..07a09e33c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "Pillow>=10.2,<12", "py7zr~=1.1.3", "pydantic~=2.10", - "pydub~=0.25", "Pygments~=2.20", "PySide6==6.11.2", "rarfile==4.2", @@ -101,7 +100,6 @@ filterwarnings = [ ignore = [ ".venv/**", "src/tagstudio/core/library/json/", - "src/tagstudio/previews/vendored/pydub/", ] include = ["src/tagstudio", "tests"] # Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md @@ -122,7 +120,6 @@ reportUnannotatedClassAttribute = false reportUninitializedInstanceVariable = false [tool.ruff] -exclude = ["home_ui.py", "resources.py", "resources_rc.py"] line-length = 100 [tool.ruff.lint] diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py index 6cf1264be5..0fb06d4856 100644 --- a/src/tagstudio/core/library/alchemy/enums.py +++ b/src/tagstudio/core/library/alchemy/enums.py @@ -6,6 +6,7 @@ import random from dataclasses import dataclass, field, replace from pathlib import Path +from typing import Any import structlog @@ -82,17 +83,15 @@ class SortingModeEnum(enum.Enum): @dataclass class BrowsingState: - """Represent a state of the Library grid view.""" + """Represent a browsing history state for a library.""" - page_index: int = 0 - page_positions: dict[int, int] = field(default_factory=dict) - sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED ascending: bool = False + first_visible_entry_id: int | None = field(default=None, compare=False) + page_index: int = 0 + query: str | None = None random_seed: float = 0 - show_hidden_entries: bool = False - - query: str | None = None + sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED # Abstract Syntax Tree Of the current Search Query @property @@ -141,19 +140,23 @@ def from_tag_name(cls, tag_name: str) -> BrowsingState: return cls(query=f'tag:"{tag_name}"') def with_page_index(self, index: int) -> BrowsingState: - return replace(self, page_index=index) + return replace(self, page_index=index, first_visible_entry_id=None) def with_sorting_mode(self, mode: SortingModeEnum) -> BrowsingState: seed = self.random_seed if mode == SortingModeEnum.RANDOM: seed = random.random() - return replace(self, sorting_mode=mode, random_seed=seed) + return self._with_new_results(sorting_mode=mode, random_seed=seed) def with_sorting_direction(self, ascending: bool) -> BrowsingState: - return replace(self, ascending=ascending) + return self._with_new_results(ascending=ascending) def with_search_query(self, search_query: str) -> BrowsingState: - return replace(self, query=search_query) + return self._with_new_results(query=search_query) def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState: - return replace(self, show_hidden_entries=show_hidden_entries) + return self._with_new_results(show_hidden_entries=show_hidden_entries) + + def _with_new_results(self, **changes: Any) -> BrowsingState: + """Copy this state with changes to its results, starting back on the first page.""" + return replace(self, page_index=0, first_visible_entry_id=None, **changes) diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py index 0f365c9e15..068ee99a6c 100644 --- a/src/tagstudio/core/library/alchemy/library.py +++ b/src/tagstudio/core/library/alchemy/library.py @@ -600,7 +600,7 @@ def get_entries(self, entry_ids: Iterable[int]) -> list[Entry]: with Session(self.engine) as session: statement = select(Entry).where(Entry.id.in_(entry_ids)) entries = dict((e.id, e) for e in session.scalars(statement)) - return [entries[id] for id in entry_ids] + return [entries[id] for id in entry_ids if id in entries] def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]: """Load entry and join with all joins and all tags.""" diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py index 6391351f47..a243e047b1 100644 --- a/src/tagstudio/previews/renderers/audio.py +++ b/src/tagstudio/previews/renderers/audio.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: MIT +import subprocess from io import BytesIO from pathlib import Path from typing import override @@ -17,15 +18,17 @@ from tagstudio.core.enums import Theme from tagstudio.core.media_types import MediaTypes +from tagstudio.core.utils.ffmpeg_status import FfmpegStatus +from tagstudio.core.utils.silent_subprocess import silent_run +from tagstudio.core.utils.types import unwrap from tagstudio.previews.base_preview import RENDER, BasePreview from tagstudio.previews.effects import apply_overlay_color -from tagstudio.previews.vendored.pydub.audio_segment import ( - _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage] -) from tagstudio.qt.views.styles.palette import UiColor logger = structlog.get_logger(__name__) +_WAVEFORM_SAMPLE_RATE = 8000 # Good enough for the waveforms while still being small bits of data + class AudioPreview(BasePreview): media_type_name = "audio" @@ -120,8 +123,13 @@ def audio_waveform_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) -> try: bar_count: int = 32 - audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[1:]) # pyright: ignore[reportUnknownVariableType] - data = np.frombuffer(buffer=audio._data, dtype=f" current_x = current_x + line_width + bar_margin - im.resize(size, Resampling.BILINEAR) + im = im.resize(size, Resampling.BILINEAR) im = apply_overlay_color(im, UiColor.GREEN, theme) except Exception as e: diff --git a/src/tagstudio/previews/vendored/probe.py b/src/tagstudio/previews/vendored/probe.py index 632afa0fdd..e03b260bf9 100644 --- a/src/tagstudio/previews/vendored/probe.py +++ b/src/tagstudio/previews/vendored/probe.py @@ -35,7 +35,7 @@ def probe(filename: Path | str, timeout: int | None = None, **kwargs: ...): args += [filename] # pyright: ignore # PATCHED - p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + p = silent_popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=False) communicate_kwargs = {} if timeout is not None: communicate_kwargs["timeout"] = timeout diff --git a/src/tagstudio/previews/vendored/pydub/audio_segment.py b/src/tagstudio/previews/vendored/pydub/audio_segment.py deleted file mode 100644 index 90aa005a82..0000000000 --- a/src/tagstudio/previews/vendored/pydub/audio_segment.py +++ /dev/null @@ -1,1442 +0,0 @@ -# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only -# Vendored from pydub - - -import array -import base64 -import os -import struct -import subprocess -import sys -import wave -from collections import namedtuple -from io import BytesIO, StringIO -from tempfile import NamedTemporaryFile - -from pydub.logging_utils import log_conversion, log_subprocess_output -from pydub.utils import fsdecode - -from tagstudio.core.utils.ffmpeg_status import FfmpegStatus - -try: - from itertools import izip -except Exception: - izip = zip - -from pydub.exceptions import ( - CouldntDecodeError, - CouldntEncodeError, - InvalidDuration, - InvalidID3TagVersion, - InvalidTag, - MissingAudioParameter, - TooManyMissingFrames, -) -from pydub.utils import ( - _fd_or_path_or_tempfile, - audioop, - db_to_float, - get_array_type, - ratio_to_db, -) - -from tagstudio.core.utils.silent_subprocess import silent_popen -from tagstudio.previews.vendored.pydub.utils import _mediainfo_json - -basestring = str -xrange = range -StringIO = BytesIO # noqa: F811 - - -class ClassPropertyDescriptor: - def __init__(self, fget, fset=None): - self.fget = fget - self.fset = fset - - def __get__(self, obj, klass=None): - if klass is None: - klass = type(obj) - return self.fget.__get__(obj, klass)() - - def __set__(self, obj, value): - if not self.fset: - raise AttributeError("can't set attribute") - type_ = type(obj) - return self.fset.__get__(obj, type_)(value) - - def setter(self, func): - if not isinstance(func, (classmethod, staticmethod)): - func = classmethod(func) - self.fset = func - return self - - -def classproperty(func): - if not isinstance(func, (classmethod, staticmethod)): - func = classmethod(func) - - return ClassPropertyDescriptor(func) - - -AUDIO_FILE_EXT_ALIASES = { - "m4a": "mp4", - "m4r": "mp4", - "wave": "wav", - "aif": "aiff", - "aifc": "aiff", -} - -WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"]) -WavData = namedtuple( - "WavData", ["audio_format", "channels", "sample_rate", "bits_per_sample", "raw_data"] -) - - -def extract_wav_headers(data): - # def search_subchunk(data, subchunk_id): - pos = 12 # The size of the RIFF chunk descriptor - subchunks = [] - while pos + 8 <= len(data) and len(subchunks) < 10: - subchunk_id = data[pos : pos + 4] - subchunk_size = struct.unpack_from(" 2**32: - raise CouldntDecodeError("Unable to process >4GB files") - - # Set the file size in the RIFF chunk descriptor - data[4:8] = struct.pack(" b"\x7f"[0]]) - old_bytes = struct.pack(pack_fmt, b0, b1, b2) - byte_buffer.write(old_bytes) - - self._data = byte_buffer.getvalue() - self.sample_width = 4 - self.frame_width = self.channels * self.sample_width - - super().__init__(*args, **kwargs) - - @property - def raw_data(self): - """Public access to the raw audio data as a bytestring.""" - return self._data - - def get_array_of_samples(self, array_type_override=None): - """Return the raw_data as an array of samples.""" - if array_type_override is None: - array_type_override = self.array_type - return array.array(array_type_override, self._data) - - @property - def array_type(self): - return get_array_type(self.sample_width * 8) - - def __len__(self): - """Return the length of this audio segment in milliseconds.""" - return round(1000 * (self.frame_count() / self.frame_rate)) - - def __eq__(self, other): - try: - return self._data == other._data - except Exception: - return False - - def __hash__(self): - return hash(_AudioSegment) ^ hash( - (self.channels, self.frame_rate, self.sample_width, self._data) - ) - - def __ne__(self, other): - return not (self == other) - - def __iter__(self): - return (self[i] for i in xrange(len(self))) - - def __getitem__(self, millisecond): - if isinstance(millisecond, slice): - if millisecond.step: - return ( - self[i : i + millisecond.step] for i in xrange(*millisecond.indices(len(self))) - ) - - start = millisecond.start if millisecond.start is not None else 0 - end = millisecond.stop if millisecond.stop is not None else len(self) - - start = min(start, len(self)) - end = min(end, len(self)) - else: - start = millisecond - end = millisecond + 1 - - start = self._parse_position(start) * self.frame_width - end = self._parse_position(end) * self.frame_width - data = self._data[start:end] - - # ensure the output is as long as the requester is expecting - expected_length = end - start - missing_frames = (expected_length - len(data)) // self.frame_width - if missing_frames: - if missing_frames > self.frame_count(ms=2): - raise TooManyMissingFrames( - "You should never be filling in " # noqa: UP031 - " more than 2 ms with silence here, " - "missing frames: %s" % missing_frames - ) - silence = audioop.mul(data[: self.frame_width], self.sample_width, 0) - data += silence * missing_frames - - return self._spawn(data) - - def get_sample_slice(self, start_sample=None, end_sample=None): - """Get a section of the audio segment by sample index. - - NOTE: Negative indices do *not* address samples backword - from the end of the audio segment like a python list. - This is intentional. - """ - max_val = int(self.frame_count()) - - def bounded(val, default): - if val is None: - return default - if val < 0: - return 0 - if val > max_val: - return max_val - return val - - start_i = bounded(start_sample, 0) * self.frame_width - end_i = bounded(end_sample, max_val) * self.frame_width - - data = self._data[start_i:end_i] - return self._spawn(data) - - def __add__(self, arg): - if isinstance(arg, _AudioSegment): - return self.append(arg, crossfade=0) - else: - return self.apply_gain(arg) - - def __radd__(self, rarg): - """Permit use of sum() builtin with an iterable of AudioSegments.""" - if rarg == 0: - return self - raise TypeError("Gains must be the second addend after the AudioSegment") - - def __sub__(self, arg): - if isinstance(arg, _AudioSegment): - raise TypeError("AudioSegment objects can't be subtracted from each other") - else: - return self.apply_gain(-arg) - - def __mul__(self, arg): - """If the argument is an AudioSegment, overlay the multiplied audio segment. - - If it's a number, just use the string multiply operation to repeat the - audio. - - The following would return an AudioSegment that contains the - audio of audio_seg eight times - - `audio_seg * 8` - """ - if isinstance(arg, _AudioSegment): - return self.overlay(arg, position=0, loop=True) - else: - return self._spawn(data=self._data * arg) - - def _spawn(self, data, overrides={}): # noqa: B006 - """Create a new audio segment using the metadata from the current one & the data passed in. - - Should be used whenever an AudioSegment is being returned by an operation that would alters - the current one, since AudioSegment objects are immutable. - """ - # accept lists of data chunks - if isinstance(data, list): - data = b"".join(data) - - if isinstance(data, array.array): - try: - data = data.tobytes() - except Exception: - data = data.tostring() - - # accept file-like objects - if hasattr(data, "read"): - if hasattr(data, "seek"): - data.seek(0) - data = data.read() - - metadata = { - "sample_width": self.sample_width, - "frame_rate": self.frame_rate, - "frame_width": self.frame_width, - "channels": self.channels, - } - metadata.update(overrides) - return self.__class__(data=data, metadata=metadata) - - @classmethod - def _sync(cls, *segs): - channels = max(seg.channels for seg in segs) - frame_rate = max(seg.frame_rate for seg in segs) - sample_width = max(seg.sample_width for seg in segs) - - return tuple( - seg.set_channels(channels).set_frame_rate(frame_rate).set_sample_width(sample_width) - for seg in segs - ) - - def _parse_position(self, val): - if val < 0: - val = len(self) - abs(val) - val = self.frame_count(ms=len(self)) if val == float("inf") else self.frame_count(ms=val) - return int(val) - - @classmethod - def empty(cls): - return cls( - b"", metadata={"channels": 1, "sample_width": 1, "frame_rate": 1, "frame_width": 1} - ) - - @classmethod - def silent(cls, duration=1000, frame_rate=11025): - """Generate a silent audio segment. - - Duration specified in milliseconds (default duration: 1000ms, default frame_rate: 11025). - """ - frames = int(frame_rate * (duration / 1000.0)) - data = b"\0\0" * frames - return cls( - data, - metadata={"channels": 1, "sample_width": 2, "frame_rate": frame_rate, "frame_width": 2}, - ) - - @classmethod - def from_mono_audiosegments(cls, *mono_segments): - if not len(mono_segments): - raise ValueError("At least one AudioSegment instance is required") - - segs = cls._sync(*mono_segments) - - if segs[0].channels != 1: - raise ValueError( - "AudioSegment.from_mono_audiosegments requires all " - "arguments are mono AudioSegment instances" - ) - - channels = len(segs) - sample_width = segs[0].sample_width - frame_rate = segs[0].frame_rate - - frame_count = max(int(seg.frame_count()) for seg in segs) - data = array.array(segs[0].array_type, b"\0" * (frame_count * sample_width * channels)) - - for i, seg in enumerate(segs): - data[i::channels] = seg.get_array_of_samples() - - return cls( - data, - channels=channels, - sample_width=sample_width, - frame_rate=frame_rate, - ) - - @classmethod - def from_file_using_temporary_files( - cls, - file, - format=None, - codec=None, - parameters=None, - start_second=None, - duration=None, - **kwargs, - ): - orig_file = file - file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) - - if format: - format = format.lower() - format = AUDIO_FILE_EXT_ALIASES.get(format, format) - - def is_format(f): - f = f.lower() - if format == f: - return True - if isinstance(orig_file, basestring): - return orig_file.lower().endswith(f".{f}") - if isinstance(orig_file, bytes): - return orig_file.lower().endswith((f".{f}").encode()) - return False - - if is_format("wav"): - try: - obj = cls._from_safe_wav(file) - if close_file: - file.close() - if start_second is None and duration is None: - return obj - elif start_second is not None and duration is None: - return obj[start_second * 1000 :] - elif start_second is None and duration is not None: - return obj[: duration * 1000] - else: - return obj[start_second * 1000 : (start_second + duration) * 1000] - except Exception: - file.seek(0) - elif is_format("raw") or is_format("pcm"): - sample_width = kwargs["sample_width"] - frame_rate = kwargs["frame_rate"] - channels = kwargs["channels"] - metadata = { - "sample_width": sample_width, - "frame_rate": frame_rate, - "channels": channels, - "frame_width": channels * sample_width, - } - obj = cls(data=file.read(), metadata=metadata) - if close_file: - file.close() - if start_second is None and duration is None: - return obj - elif start_second is not None and duration is None: - return obj[start_second * 1000 :] - elif start_second is None and duration is not None: - return obj[: duration * 1000] - else: - return obj[start_second * 1000 : (start_second + duration) * 1000] - - input_file = NamedTemporaryFile(mode="wb", delete=False) - try: - input_file.write(file.read()) - except OSError: - input_file.flush() - input_file.close() - input_file = NamedTemporaryFile(mode="wb", delete=False, buffering=2**31 - 1) - if close_file: - file.close() - close_file = True - file = open(orig_file, buffering=2**13 - 1, mode="rb") - reader = file.read(2**31 - 1) - while reader: - input_file.write(reader) - reader = file.read(2**31 - 1) - input_file.flush() - if close_file: - file.close() - - output = NamedTemporaryFile(mode="rb", delete=False) - - conversion_command = [ - cls.converter, - "-y", # always overwrite existing files - ] - - # If format is not defined - # ffmpeg/avconv will detect it automatically - if format: - conversion_command += ["-f", format] - - if codec: - # force audio decoder - conversion_command += ["-acodec", codec] - - conversion_command += [ - "-i", - input_file.name, # input_file options (filename last) - "-vn", # Drop any video streams if there are any - "-f", - "wav", # output options (filename last) - ] - - if start_second is not None: - conversion_command += ["-ss", str(start_second)] - - if duration is not None: - conversion_command += ["-t", str(duration)] - - conversion_command += [output.name] - - if parameters is not None: - # extend arguments with arbitrary set - conversion_command.extend(parameters) - - log_conversion(conversion_command) - - with open(os.devnull, "rb") as devnull: - # PATCHED - p = silent_popen( - conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - p_out, p_err = p.communicate() - - log_subprocess_output(p_out) - log_subprocess_output(p_err) - - try: - if p.returncode != 0: - raise CouldntDecodeError( - "Decoding failed. ffmpeg returned error code: " # noqa: UP030 - "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( - p.returncode, p_err.decode(errors="ignore") - ) - ) - obj = cls._from_safe_wav(output) - finally: - input_file.close() - output.close() - os.unlink(input_file.name) - os.unlink(output.name) - - if start_second is None and duration is None: - return obj - elif start_second is not None and duration is None: - return obj[0:] - elif start_second is None and duration is not None: - return obj[: duration * 1000] - else: - return obj[0 : duration * 1000] - - @classmethod - def from_file( - cls, - file, - format=None, - codec=None, - parameters=None, - start_second=None, - duration=None, - **kwargs, - ): - orig_file = file - try: - filename = fsdecode(file) - except TypeError: - filename = None - file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) - - if format: - format = format.lower() - format = AUDIO_FILE_EXT_ALIASES.get(format, format) - - def is_format(f): - f = f.lower() - if format == f: - return True - - if filename: - return filename.lower().endswith(f".{f}") - - return False - - if is_format("wav"): - try: - if start_second is None and duration is None: - return cls._from_safe_wav(file) - elif start_second is not None and duration is None: - return cls._from_safe_wav(file)[start_second * 1000 :] - elif start_second is None and duration is not None: - return cls._from_safe_wav(file)[: duration * 1000] - else: - return cls._from_safe_wav(file)[ - start_second * 1000 : (start_second + duration) * 1000 - ] - except Exception: - file.seek(0) - elif is_format("raw") or is_format("pcm"): - sample_width = kwargs["sample_width"] - frame_rate = kwargs["frame_rate"] - channels = kwargs["channels"] - metadata = { - "sample_width": sample_width, - "frame_rate": frame_rate, - "channels": channels, - "frame_width": channels * sample_width, - } - if start_second is None and duration is None: - return cls(data=file.read(), metadata=metadata) - elif start_second is not None and duration is None: - return cls(data=file.read(), metadata=metadata)[start_second * 1000 :] - elif start_second is None and duration is not None: - return cls(data=file.read(), metadata=metadata)[: duration * 1000] - else: - return cls(data=file.read(), metadata=metadata)[ - start_second * 1000 : (start_second + duration) * 1000 - ] - - conversion_command = [ - cls.converter, - "-y", # always overwrite existing files - ] - - # If format is not defined - # ffmpeg/avconv will detect it automatically - if format: - conversion_command += ["-f", format] - - if codec: - # force audio decoder - conversion_command += ["-acodec", codec] - - read_ahead_limit = kwargs.get("read_ahead_limit", -1) - if filename: - conversion_command += ["-i", filename] - stdin_parameter = None - stdin_data = None - else: - if cls.converter == FfmpegStatus.which(): - conversion_command += [ - "-read_ahead_limit", - str(read_ahead_limit), - "-i", - "cache:pipe:0", - ] - else: - conversion_command += ["-i", "-"] - stdin_parameter = subprocess.PIPE - stdin_data = file.read() - - # PATCHED - info = None if codec else _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit) - if info: - audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] - # This is a workaround for some ffprobe versions that always say - # that mp3/mp4/aac/webm/ogg files contain fltp samples - audio_codec = audio_streams[0].get("codec_name") - if audio_streams[0].get("sample_fmt") == "fltp" and audio_codec in [ - "mp3", - "mp4", - "aac", - "webm", - "ogg", - ]: - bits_per_sample = 16 - else: - bits_per_sample = audio_streams[0]["bits_per_sample"] - acodec = "pcm_u8" if bits_per_sample == 8 else "pcm_s%dle" % bits_per_sample - - conversion_command += ["-acodec", acodec] - - conversion_command += [ - "-vn", # Drop any video streams if there are any - "-f", - "wav", # output options (filename last) - ] - - if start_second is not None: - conversion_command += ["-ss", str(start_second)] - - if duration is not None: - conversion_command += ["-t", str(duration)] - - conversion_command += ["-"] - - if parameters is not None: - # extend arguments with arbitrary set - conversion_command.extend(parameters) - - log_conversion(conversion_command) - - # PATCHED - p = silent_popen( - conversion_command, - stdin=stdin_parameter, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - p_out, p_err = p.communicate(input=stdin_data) - - if p.returncode != 0 or len(p_out) == 0: - if close_file: - file.close() - raise CouldntDecodeError( - "Decoding failed. ffmpeg returned error code: " # noqa: UP030 - "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format( - p.returncode, p_err.decode(errors="ignore") - ) - ) - - p_out = bytearray(p_out) - fix_wav_headers(p_out) - p_out = bytes(p_out) - obj = cls(p_out) - - if close_file: - file.close() - - if start_second is None and duration is None: - return obj - elif start_second is not None and duration is None: - return obj[0:] - elif start_second is None and duration is not None: - return obj[: duration * 1000] - else: - return obj[0 : duration * 1000] - - @classmethod - def from_mp3(cls, file, parameters=None): - return cls.from_file(file, "mp3", parameters=parameters) - - @classmethod - def from_flv(cls, file, parameters=None): - return cls.from_file(file, "flv", parameters=parameters) - - @classmethod - def from_ogg(cls, file, parameters=None): - return cls.from_file(file, "ogg", parameters=parameters) - - @classmethod - def from_wav(cls, file, parameters=None): - return cls.from_file(file, "wav", parameters=parameters) - - @classmethod - def from_raw(cls, file, **kwargs): - return cls.from_file( - file, - "raw", - sample_width=kwargs["sample_width"], - frame_rate=kwargs["frame_rate"], - channels=kwargs["channels"], - ) - - @classmethod - def _from_safe_wav(cls, file): - file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False) - file.seek(0) - obj = cls(data=file) - if close_file: - file.close() - return obj - - def export( - self, - out_f=None, - format="mp3", - codec=None, - bitrate=None, - parameters=None, - tags=None, - id3v2_version="4", - cover=None, - ): - """Export an AudioSegment to a file with the given options. - - out_f (string): - Path to destination audio file. Also accepts os.PathLike objects on - python >= 3.6 - - format (string) - Format for destination audio file. - ('mp3', 'wav', 'raw', 'ogg' or other ffmpeg/avconv supported files) - - codec (string) - Codec used to encode the destination file. - - bitrate (string) - Bitrate used when encoding destination file. (64, 92, 128, 256, 312k...) - Each codec accepts different bitrate arguments so take a look at the - ffmpeg documentation for details (bitrate usually shown as -b, -ba or - -a:b). - - parameters (list of strings) - Aditional ffmpeg/avconv parameters - - tags (dict) - Set metadata information to destination files - usually used as tags. ({title='Song Title', artist='Song Artist'}) - - id3v2_version (string) - Set ID3v2 version for tags. (default: '4') - - cover (file) - Set cover for audio file from image file. (png or jpg) - """ - id3v2_allowed_versions = ["3", "4"] - - if format == "raw" and (codec is not None or parameters is not None): - raise AttributeError( - 'Can not invoke ffmpeg when export format is "raw"; ' - 'specify an ffmpeg raw format like format="s16le" instead ' - 'or call export(format="raw") with no codec or parameters' - ) - - out_f, _ = _fd_or_path_or_tempfile(out_f, "wb+") - out_f.seek(0) - - if format == "raw": - out_f.write(self._data) - out_f.seek(0) - return out_f - - # wav with no ffmpeg parameters can just be written directly to out_f - easy_wav = format == "wav" and codec is None and parameters is None - - data = out_f if easy_wav else NamedTemporaryFile(mode="wb", delete=False) - - pcm_for_wav = self._data - if self.sample_width == 1: - # convert to unsigned integers for wav - pcm_for_wav = audioop.bias(self._data, 1, 128) - - wave_data = wave.open(data, "wb") - wave_data.setnchannels(self.channels) - wave_data.setsampwidth(self.sample_width) - wave_data.setframerate(self.frame_rate) - # For some reason packing the wave header struct with - # a float in python 2 doesn't throw an exception - wave_data.setnframes(int(self.frame_count())) - wave_data.writeframesraw(pcm_for_wav) - wave_data.close() - - # for easy wav files, we're done (wav data is written directly to out_f) - if easy_wav: - out_f.seek(0) - return out_f - - output = NamedTemporaryFile(mode="w+b", delete=False) - - # build converter command to export - conversion_command = [ - self.converter, - "-y", # always overwrite existing files - "-f", - "wav", - "-i", - data.name, # input options (filename last) - ] - - if codec is None: - codec = self.DEFAULT_CODECS.get(format, None) - - if cover is not None: - if ( - cover.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")) - and format == "mp3" - ): - conversion_command.extend(["-i", cover, "-map", "0", "-map", "1", "-c:v", "mjpeg"]) - else: - raise AttributeError( - "Currently cover images are only supported by MP3 files. The allowed image " - "formats are: .tif, .jpg, .bmp, .jpeg and .png." - ) - - if codec is not None: - # force audio encoder - conversion_command.extend(["-acodec", codec]) - - if bitrate is not None: - conversion_command.extend(["-b:a", bitrate]) - - if parameters is not None: - # extend arguments with arbitrary set - conversion_command.extend(parameters) - - if tags is not None: - if not isinstance(tags, dict): - raise InvalidTag("Tags must be a dictionary.") - else: - # Extend converter command with tags - # print(tags) - for key, value in tags.items(): - conversion_command.extend(["-metadata", f"{key}={value}"]) - - if format == "mp3": - # set id3v2 tag version - if id3v2_version not in id3v2_allowed_versions: - raise InvalidID3TagVersion( - "id3v2_version not allowed, allowed versions: %s" # noqa: UP031 - % id3v2_allowed_versions - ) - conversion_command.extend(["-id3v2_version", id3v2_version]) - - if sys.platform == "darwin" and codec == "mp3": - conversion_command.extend(["-write_xing", "0"]) - - conversion_command.extend( - [ - "-f", - format, - output.name, # output options (filename last) - ] - ) - - log_conversion(conversion_command) - - # read stdin / write stdout - with open(os.devnull, "rb") as devnull: - # PATCHED - p = silent_popen( - conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - p_out, p_err = p.communicate() - - log_subprocess_output(p_out) - log_subprocess_output(p_err) - - try: - if p.returncode != 0: - raise CouldntEncodeError( - "Encoding failed. ffmpeg/avlib returned error code: " # noqa: UP030 - "{0}\n\nCommand:{1}\n\nOutput from ffmpeg/avlib:\n\n{2}".format( - p.returncode, conversion_command, p_err.decode(errors="ignore") - ) - ) - - output.seek(0) - out_f.write(output.read()) - - finally: - data.close() - output.close() - os.unlink(data.name) - os.unlink(output.name) - - out_f.seek(0) - return out_f - - def get_frame(self, index): - frame_start = index * self.frame_width - frame_end = frame_start + self.frame_width - return self._data[frame_start:frame_end] - - def frame_count(self, ms=None): - """Return the number of frames for the given number of milliseconds. - - If not specified, return the number of frames in the whole AudioSegment - """ - if ms is not None: - return ms * (self.frame_rate / 1000.0) - else: - return float(len(self._data) // self.frame_width) - - def set_sample_width(self, sample_width): - if sample_width == self.sample_width: - return self - - frame_width = self.channels * sample_width - - return self._spawn( - audioop.lin2lin(self._data, self.sample_width, sample_width), - overrides={"sample_width": sample_width, "frame_width": frame_width}, - ) - - def set_frame_rate(self, frame_rate): - if frame_rate == self.frame_rate: - return self - - if self._data: - converted, _ = audioop.ratecv( - self._data, self.sample_width, self.channels, self.frame_rate, frame_rate, None - ) - else: - converted = self._data - - return self._spawn(data=converted, overrides={"frame_rate": frame_rate}) - - def set_channels(self, channels): - if channels == self.channels: - return self - - if channels == 2 and self.channels == 1: - fn = audioop.tostereo - frame_width = self.frame_width * 2 - fac = 1 - converted = fn(self._data, self.sample_width, fac, fac) - elif channels == 1 and self.channels == 2: - fn = audioop.tomono - frame_width = self.frame_width // 2 - fac = 0.5 - converted = fn(self._data, self.sample_width, fac, fac) - elif channels == 1: - channels_data = [seg.get_array_of_samples() for seg in self.split_to_mono()] - frame_count = int(self.frame_count()) - converted = array.array( - channels_data[0].typecode, b"\0" * (frame_count * self.sample_width) - ) - for raw_channel_data in channels_data: - for i in range(frame_count): - converted[i] += raw_channel_data[i] // self.channels - frame_width = self.frame_width // self.channels - elif self.channels == 1: - dup_channels = [self for iChannel in range(channels)] - return _AudioSegment.from_mono_audiosegments(*dup_channels) - else: - raise ValueError( - "AudioSegment.set_channels only supports mono-to-multi channel " - "and multi-to-mono channel conversion" - ) - - return self._spawn( - data=converted, overrides={"channels": channels, "frame_width": frame_width} - ) - - def split_to_mono(self): - if self.channels == 1: - return [self] - - samples = self.get_array_of_samples() - - mono_channels = [] - for i in range(self.channels): - samples_for_current_channel = samples[i :: self.channels] - - try: - mono_data = samples_for_current_channel.tobytes() - except AttributeError: - mono_data = samples_for_current_channel.tostring() - - mono_channels.append( - self._spawn(mono_data, overrides={"channels": 1, "frame_width": self.sample_width}) - ) - - return mono_channels - - @property - def rms(self): - return audioop.rms(self._data, self.sample_width) - - @property - def dBFS(self): # noqa: N802 - rms = self.rms - if not rms: - return -float("infinity") - return ratio_to_db(self.rms / self.max_possible_amplitude) - - @property - def max(self): - return audioop.max(self._data, self.sample_width) - - @property - def max_possible_amplitude(self): - bits = self.sample_width * 8 - max_possible_val = 2**bits - - # since half is above 0 and half is below the max amplitude is divided - return max_possible_val / 2 - - @property - def max_dBFS(self): - return ratio_to_db(self.max, self.max_possible_amplitude) - - @property - def duration_seconds(self): - return self.frame_rate and self.frame_count() / self.frame_rate or 0.0 - - def get_dc_offset(self, channel=1): - """Return a value between -1.0 and 1.0 representing the DC offset of a channel. - - 1 for left, 2 for right. - """ - if not 1 <= channel <= 2: - raise ValueError("channel value must be 1 (left) or 2 (right)") - - if self.channels == 1: - data = self._data - elif channel == 1: - data = audioop.tomono(self._data, self.sample_width, 1, 0) - else: - data = audioop.tomono(self._data, self.sample_width, 0, 1) - - return float(audioop.avg(data, self.sample_width)) / self.max_possible_amplitude - - def remove_dc_offset(self, channel=None, offset=None): - """Remove DC offset of given channel. Calculates offset if it's not given. - - Offset values must be in range -1.0 to 1.0. If channel is None, removes - DC offset from all available channels. - """ - if channel and not 1 <= channel <= 2: - raise ValueError("channel value must be None, 1 (left) or 2 (right)") - - if offset and not -1.0 <= offset <= 1.0: - raise ValueError("offset value must be in range -1.0 to 1.0") - - if offset: - offset = int(round(offset * self.max_possible_amplitude)) - - def remove_data_dc(data, off): - if not off: - off = audioop.avg(data, self.sample_width) - return audioop.bias(data, self.sample_width, -off) - - if self.channels == 1: - return self._spawn(data=remove_data_dc(self._data, offset)) - - left_channel = audioop.tomono(self._data, self.sample_width, 1, 0) - right_channel = audioop.tomono(self._data, self.sample_width, 0, 1) - - if not channel or channel == 1: - left_channel = remove_data_dc(left_channel, offset) - - if not channel or channel == 2: - right_channel = remove_data_dc(right_channel, offset) - - left_channel = audioop.tostereo(left_channel, self.sample_width, 1, 0) - right_channel = audioop.tostereo(right_channel, self.sample_width, 0, 1) - - return self._spawn(data=audioop.add(left_channel, right_channel, self.sample_width)) - - def apply_gain(self, volume_change): - return self._spawn( - data=audioop.mul(self._data, self.sample_width, db_to_float(float(volume_change))) - ) - - def overlay(self, seg, position=0, loop=False, times=None, gain_during_overlay=None): - """Overlay the provided segment on to this segment. - - Starts at the specified position and uses the specified looping behavior. - - seg (AudioSegment): - The audio segment to overlay on to this one. - - position (optional int): - The position to start overlaying the provided segment in to this - one. - - loop (optional bool): - Loop seg as many times as necessary to match this segment's length. - Overrides loops param. - - times (optional int): - Loop seg the specified number of times or until it matches this - segment's length. 1 means once, 2 means twice, ... 0 would make the - call a no-op - gain_during_overlay (optional int): - Changes this segment's volume by the specified amount during the - duration of time that seg is overlaid on top of it. When negative, - this has the effect of 'ducking' the audio under the overlay. - """ - if loop: - # match loop=True's behavior with new times (count) mechanism. - times = -1 - elif times is None: - # no times specified, just once through - times = 1 - elif times == 0: - # it's a no-op, make a copy since we never mutate - return self._spawn(self._data) - - output = StringIO() - - seg1, seg2 = _AudioSegment._sync(self, seg) - sample_width = seg1.sample_width - spawn = seg1._spawn - - output.write(seg1[:position]._data) - - # drop down to the raw data - seg1 = seg1[position:]._data - seg2 = seg2._data - pos = 0 - seg1_len = len(seg1) - seg2_len = len(seg2) - while times: - remaining = max(0, seg1_len - pos) - if seg2_len >= remaining: - seg2 = seg2[:remaining] - seg2_len = remaining - # we've hit the end, we're done looping (if we were) and this - # is our last go-around - times = 1 - - if gain_during_overlay: - seg1_overlaid = seg1[pos : pos + seg2_len] - seg1_adjusted_gain = audioop.mul( - seg1_overlaid, self.sample_width, db_to_float(float(gain_during_overlay)) - ) - output.write(audioop.add(seg1_adjusted_gain, seg2, sample_width)) - else: - output.write(audioop.add(seg1[pos : pos + seg2_len], seg2, sample_width)) - pos += seg2_len - - # dec times to break our while loop (eventually) - times -= 1 - - output.write(seg1[pos:]) - - return spawn(data=output) - - def append(self, seg, crossfade=100): - seg1, seg2 = _AudioSegment._sync(self, seg) - - if not crossfade: - return seg1._spawn(seg1._data + seg2._data) - elif crossfade > len(self): - raise ValueError( - f"Crossfade is longer than the original AudioSegment ({crossfade}ms > {len(self)}ms)" - ) - elif crossfade > len(seg): - raise ValueError( - f"Crossfade is longer than the appended AudioSegment ({crossfade}ms > {len(seg)}ms)" - ) - - xf = seg1[-crossfade:].fade(to_gain=-120, start=0, end=float("inf")) - xf *= seg2[:crossfade].fade(from_gain=-120, start=0, end=float("inf")) - - output = BytesIO() - - output.write(seg1[:-crossfade]._data) - output.write(xf._data) - output.write(seg2[crossfade:]._data) - - output.seek(0) - obj = seg1._spawn(data=output) - output.close() - return obj - - def fade(self, to_gain=0, from_gain=0, start=None, end=None, duration=None): - """Fade the volume of this audio segment. - - to_gain (float): - resulting volume_change in db - - start (int): - default = beginning of the segment - when in this segment to start fading in milliseconds - - end (int): - default = end of the segment - when in this segment to start fading in milliseconds - - duration (int): - default = until the end of the audio segment - the duration of the fade - """ - if None not in [duration, end, start]: - raise TypeError( - 'Only two of the three arguments, "start", "end", and "duration" may be specified' - ) - - # no fade == the same audio - if to_gain == 0 and from_gain == 0: - return self - - start = min(len(self), start) if start is not None else None - end = min(len(self), end) if end is not None else None - - if start is not None and start < 0: - start += len(self) - if end is not None and end < 0: - end += len(self) - - if duration is not None and duration < 0: - raise InvalidDuration("duration must be a positive integer") - - if duration: - if start is not None: - end = start + duration - elif end is not None: - start = end - duration - else: - duration = end - start - - from_power = db_to_float(from_gain) - - output = [] - - # original data - up until the crossfade portion, as is - before_fade = self[:start]._data - if from_gain != 0: - before_fade = audioop.mul(before_fade, self.sample_width, from_power) - output.append(before_fade) - - gain_delta = db_to_float(to_gain) - from_power - - # fades longer than 100ms can use coarse fading (one gain step per ms), - # shorter fades will have audible clicks so they use precise fading - # (one gain step per sample) - if duration > 100: - scale_step = gain_delta / duration - - for i in range(duration): - volume_change = from_power + (scale_step * i) - chunk = self[start + i] - chunk = audioop.mul(chunk._data, self.sample_width, volume_change) - - output.append(chunk) - else: - start_frame = self.frame_count(ms=start) - end_frame = self.frame_count(ms=end) - fade_frames = end_frame - start_frame - scale_step = gain_delta / fade_frames - - for i in range(int(fade_frames)): - volume_change = from_power + (scale_step * i) - sample = self.get_frame(int(start_frame + i)) - sample = audioop.mul(sample, self.sample_width, volume_change) - - output.append(sample) - - # original data after the crossfade portion, at the new volume - after_fade = self[end:]._data - if to_gain != 0: - after_fade = audioop.mul(after_fade, self.sample_width, db_to_float(to_gain)) - output.append(after_fade) - - return self._spawn(data=output) - - def fade_out(self, duration): - return self.fade(to_gain=-120, duration=duration, end=float("inf")) - - def fade_in(self, duration): - return self.fade(from_gain=-120, duration=duration, start=0) - - def reverse(self): - return self._spawn(data=audioop.reverse(self._data, self.sample_width)) - - def _repr_html_(self): - src = """ - - """ - fh = self.export() - data = base64.b64encode(fh.read()).decode("ascii") - return src.format(base64=data) diff --git a/src/tagstudio/previews/vendored/pydub/utils.py b/src/tagstudio/previews/vendored/pydub/utils.py deleted file mode 100644 index e346cd0ae7..0000000000 --- a/src/tagstudio/previews/vendored/pydub/utils.py +++ /dev/null @@ -1,92 +0,0 @@ -# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only -# Vendored from pydub - - -import json -import re -import subprocess - -from pydub.utils import _fd_or_path_or_tempfile, fsdecode, get_extra_info - -from tagstudio.core.utils.ffmpeg_status import FfprobeStatus -from tagstudio.core.utils.silent_subprocess import silent_popen - - -def _mediainfo_json(filepath, read_ahead_limit=-1): - """Return json dictionary with media info(codec, duration, size, bitrate...) from filepath.""" - prober = FfprobeStatus.which() - command_args = [ - "-v", - "info", - "-show_format", - "-show_streams", - ] - try: - command_args += [fsdecode(filepath)] - stdin_parameter = None - stdin_data = None - except TypeError: - if prober == "ffprobe": - command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"] - else: - command_args += ["-"] - stdin_parameter = subprocess.PIPE - file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False) - file.seek(0) - stdin_data = file.read() - if close_file: - file.close() - - command = [prober, "-of", "json"] + command_args - # PATCHED - res = silent_popen( - command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - output, stderr = res.communicate(input=stdin_data) - output = output.decode("utf-8", "ignore") - stderr = stderr.decode("utf-8", "ignore") - - try: - info = json.loads(output) - except json.decoder.JSONDecodeError: - # If ffprobe didn't give any information, just return it - # (for example, because the file doesn't exist) - return None - if not info: - return info - - extra_info = get_extra_info(stderr) - - audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"] - if len(audio_streams) == 0: - return info - - # We just operate on the first audio stream in case there are more - stream = audio_streams[0] - - def set_property(stream, prop, value): - if prop not in stream or stream[prop] == 0: - stream[prop] = value - - for token in extra_info[stream["index"]]: - m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token) - m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token) - if m: - set_property(stream, "sample_fmt", m.group(1)) - set_property(stream, "bits_per_sample", int(m.group(2))) - set_property(stream, "bits_per_raw_sample", int(m.group(3))) - elif m2: - set_property(stream, "sample_fmt", m2.group(1)) - set_property(stream, "bits_per_sample", int(m2.group(2))) - set_property(stream, "bits_per_raw_sample", int(m2.group(2))) - elif re.match(r"(flt)p?( \(default\))?$", token): - set_property(stream, "sample_fmt", token) - set_property(stream, "bits_per_sample", 32) - set_property(stream, "bits_per_raw_sample", 32) - elif re.match(r"(dbl)p?( \(default\))?$", token): - set_property(stream, "sample_fmt", token) - set_property(stream, "bits_per_sample", 64) - set_property(stream, "bits_per_raw_sample", 64) - return info diff --git a/src/tagstudio/qt/controllers/inspector.py b/src/tagstudio/qt/controllers/inspector.py index 3b7416d5d2..d61193d9e4 100644 --- a/src/tagstudio/qt/controllers/inspector.py +++ b/src/tagstudio/qt/controllers/inspector.py @@ -50,7 +50,7 @@ def __init__(self, driver: QtDriver) -> None: self._driver = driver self._lib = self._driver.lib self._settings = self._driver.settings - self._selected: list[int] + self._selected: list[int] = [] self._current_stats: FileAttributeData | None = None self._open_tag_search_action = QShortcut( @@ -77,7 +77,6 @@ def _connect_callbacks(self) -> None: self.layout().add_tag_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.TAG)) self._open_tag_search_action.activated.connect(self._open_tag_search_callback) self.layout().tag_search_box.done.connect(partial(self._tag_added_callback)) - self.layout().containers.on_tags_update.connect(self._update_added_callback) # Field Search self.layout().add_field_button.clicked.connect(lambda: self._set_item_mode(_ItemMode.FIELD)) @@ -88,6 +87,13 @@ def _connect_callbacks(self) -> None: self.layout().preview_thumb.stats_updated.connect(self._thumb_stats_updated_callback) self.layout().preview_thumb.check_ffmpeg.connect(self._toggle_ffmpeg_warning) + self._driver.entry_state.tags_changed.connect(self._on_tags_changed) + + def _on_tags_changed(self, entry_ids: set[int]) -> None: + if len(self._selected) == 1 and self._selected[0] in entry_ids: + self.layout().containers.update_from_entry(self._selected[0]) + self.layout().tag_search_box.added = self.layout().containers.tags + def _set_item_mode(self, mode: _ItemMode | None): def hide_and_disable_buttons(): self.layout().add_tag_button.setHidden(True) @@ -128,7 +134,6 @@ def _tag_added_callback(self, query: str): self._set_item_mode(None) self.layout().add_tag_button.setFocus() else: - self._update_added_callback() self.layout().tag_search_box.layout().search_field.setFocus() def _field_added_callback(self, query: str): @@ -138,9 +143,6 @@ def _field_added_callback(self, query: str): else: self.layout().field_search_box.layout().search_field.setFocus() - def _update_added_callback(self): - self.layout().tag_search_box.added = self.layout().containers.tags - def _thumb_stats_updated_callback(self, filepath: Path, stats: FileAttributeData) -> None: if len(self._selected) != 1: return @@ -215,8 +217,6 @@ def _edit_field(self, entry_id: int, field: BaseField) -> None: def _add_tag_to_selected(self, tag_id: int) -> None: self.layout().containers.add_tags_to_selected(tag_id) - if len(self._selected) == 1: - self.layout().containers.update_from_entry(self._selected[0]) def _toggle_ffmpeg_warning(self, enable_warning: bool = True) -> None: if enable_warning and (not FfmpegStatus.which() or not FfprobeStatus.which()): diff --git a/src/tagstudio/qt/controllers/item_thumb.py b/src/tagstudio/qt/controllers/item_thumb.py new file mode 100644 index 0000000000..363db35a0a --- /dev/null +++ b/src/tagstudio/qt/controllers/item_thumb.py @@ -0,0 +1,395 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from pathlib import Path +from typing import TYPE_CHECKING, override + +import structlog +from PySide6.QtCore import QEvent, QMimeData, QPoint, QRect, QRectF, QSignalBlocker, Qt, QUrl +from PySide6.QtGui import ( + QColor, + QContextMenuEvent, + QDrag, + QEnterEvent, + QGuiApplication, + QMouseEvent, + QPainter, + QPainterPath, + QPaintEvent, + QPen, + QPixmap, +) +from PySide6.QtWidgets import QApplication, QMenu, QWidget + +from tagstudio.core.library.alchemy.enums import ItemType +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.media_types import MediaTypes +from tagstudio.core.query_lang.file_groups import SEARCH +from tagstudio.core.utils.decompression import get_display_ext +from tagstudio.core.utils.str_formatting import format_duration +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.utils.file_opener import FileOpenerHelper +from tagstudio.qt.views.item_thumb_view import ItemThumbView +from tagstudio.qt.views.styles.palette import Palette +from tagstudio.qt.views.styles.stylesheets import ( + RADIUS, + item_thumb_faded_select_color, + item_thumb_filename_style, + item_thumb_hover_color, + item_thumb_label_style, +) + +if TYPE_CHECKING: + from tagstudio.core.library.alchemy.models import Entry + from tagstudio.qt.qt_driver import QtDriver + +logger = structlog.get_logger(__name__) + + +class ItemThumb(QWidget): + """The thumbnail widget for a library item (Entry, Entry Group, etc.).""" + + def __init__( + self, + mode: ItemType | None, + library: Library, + driver: QtDriver, + thumb_size: tuple[int, int], + show_filename_label: bool = False, + ): + super().__init__() + self._driver = driver + self._lib = library + self._opener = FileOpenerHelper(Path()) + + self._hover_color = item_thumb_hover_color() + self._select_color = Palette.accent_alt() + self._select_color_faded = item_thumb_faded_select_color() + + self._image_hovered: bool = False + self._image: QPixmap | None = None + self._item_path: Path | None = None + self._mode: ItemType | None = mode + self._press_pos: QPoint | None = None + self._select_on_release: bool = False + self._selected: bool = False + self._show_filename_label: bool = show_filename_label + self._thumb_size: tuple[int, int] = thumb_size + self.item_id: int = -1 + self.rendered_path: Path | None = None + + self.setMouseTracking(True) + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + self.setFixedSize(*thumb_size) + self.setLayout(ItemThumbView(driver.entry_state.badge_tag_ids)) + self._connect_callbacks() + self.set_filename_visibility(show_filename_label) + self.set_mode(mode) + + @override + def layout(self) -> ItemThumbView: + return super().layout() # pyright: ignore[reportReturnType] + + def _connect_callbacks(self) -> None: + view = self.layout() + view.open_file_action.triggered.connect(self._opener.open_file) + view.open_explorer_action.triggered.connect(self._opener.open_explorer) + view.delete_action.triggered.connect(self._on_delete) + for tag_id, badge in view.badges.items(): + badge.stateChanged.connect(lambda _, t=tag_id: self._on_badge_check(t)) + + def _on_delete(self) -> None: + if self.item_id != -1 and self._item_path is not None: + self._driver.delete_files_callback(self._item_path, self.item_id) + + def _toggle_item_selection(self) -> None: + self._driver.toggle_item_selection( + self.item_id, + append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier), + bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier), + ) + + def _image_rect(self) -> QRect: + return QRect(0, 0, *self._thumb_size) + + def _set_image_hovered(self, hovered: bool) -> None: + if hovered == self._image_hovered: + return + self._image_hovered = hovered + self._show_check_badges(show=hovered) + if hovered: + self.setCursor(Qt.CursorShape.PointingHandCursor) + else: + self.unsetCursor() + self.update() + + def set_mode(self, mode: ItemType | None) -> None: + view = self.layout() + if mode is None: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True) + self._set_image_hovered(hovered=False) + elif mode == ItemType.ENTRY: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) + view.badge_container.setHidden(False) + # Count Label depends on file extension (video length, word count) + view.item_type_icon.setHidden(True) + view.count_label.setStyleSheet(item_thumb_label_style()) + view.count_label.setHidden(True) + view.ext_label.setHidden(True) + elif mode == ItemType.ENTRY_GROUP: + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) + view.badge_container.setHidden(True) + view.ext_label.setHidden(True) + view.count_label.setStyleSheet(item_thumb_label_style(is_large=True)) + view.count_label.setHidden(False) + view.item_type_icon.setHidden(False) + self._mode = mode + self.update() + + def set_extension(self, filename: Path) -> None: + show_ext_label = False + show_count_label = False + + # TODO: Make these configurable by the user (on, off, or custom) + ext = get_display_ext(filename) + if ext and ext.startswith(".") is False: + ext = "." + ext + if ( + not MediaTypes.contains("image.raster", ext, SEARCH) + or MediaTypes.contains("image.raster.raw", ext, SEARCH) + or MediaTypes.contains("image.vector", ext, SEARCH) + or MediaTypes.contains("adobe.photoshop", ext, SEARCH) + or ext + in [ + ".apng", + ".avif", + ".exr", + ".gif", + ".jxl", + ".webp", + ] + ) and (ext or filename.stem): + self.layout().ext_label.setText(ext.upper()[1:] or filename.stem.upper()) + show_ext_label = True + + if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains("audio", ext, SEARCH): + show_count_label = True + + self.layout().ext_label.setHidden(not show_ext_label) + self.layout().count_label.setHidden(not show_count_label) + self._apply_label_sizes() + + def set_count(self, count: str) -> None: + view = self.layout() + if count: + view.count_label.setHidden(False) + view.count_label.setText(count) + else: + if self._mode == ItemType.ENTRY: + view.ext_label.setHidden(True) + view.count_label.setHidden(True) + self._apply_label_sizes() + + def set_duration(self, duration: float | None) -> None: + """Show a media duration on the count label, or "-:--" while it's unknown.""" + count_label = self.layout().count_label + if duration is None: + count_label.setText("-:--") + else: + count_label.setText(format_duration(duration)) + count_label.setHidden(False) + self._apply_label_sizes() + + def set_filename_text(self, filename: Path): + self.layout().file_label.setText(str(filename.name)) + self._apply_label_sizes() + + def _apply_label_sizes(self) -> None: + self.layout().activate() + + def set_filename_visibility(self, set_visible: bool): + """Toggle the visibility of the filename label. + + Args: + set_visible (bool): Show the filename, true or false. + """ + file_label = self.layout().file_label + if set_visible: + if file_label.isHidden(): + file_label.setHidden(False) + self.setFixedHeight( + self._thumb_size[1] + + ItemThumbView.FILE_LABEL_HEIGHT + + ItemThumbView.FILE_LABEL_SPACING + ) + else: + file_label.setHidden(True) + self.setFixedHeight(self._thumb_size[1]) + self._show_filename_label = set_visible + + def set_selected(self, value: bool) -> None: + if value != self._selected: + self._selected = value + self.layout().file_label.setStyleSheet(item_thumb_filename_style(is_selected=value)) + self.update() + + def set_thumb_size(self, size: int) -> None: + self._thumb_size = (size, size) + self.setFixedSize(size, size) + self.set_filename_visibility(self._show_filename_label) + self.update() + + def set_image(self, image: QPixmap, file_path: Path) -> None: + self._image = image + self.rendered_path = file_path + self.update() + + def clear_image(self) -> None: + self._image = None + self.rendered_path = None + self.update() + + def set_item(self, entry: Entry): + self.set_item_id(entry.id) + self._item_path = unwrap(self._lib.library_dir) / entry.path + self._opener.set_filepath(self._item_path) + + def set_item_id(self, item_id: int): + self.item_id = item_id + self._item_path = None + + def assign_badge(self, tag_id: int, value: bool) -> None: + badge = self.layout().badges[tag_id] + if badge.isChecked() != value: + with QSignalBlocker(badge): + badge.setChecked(value) + badge.setHidden(not value) + + def _show_check_badges(self, show: bool): + if self._mode != ItemType.TAG_GROUP: + for badge in self.layout().badges.values(): + badge.setHidden(not (show or badge.isChecked())) + + def _on_badge_check(self, tag_id: int): + selected = self._driver.selected + entry_ids = selected if self.item_id in selected else [self.item_id] + is_checked = self.layout().badges[tag_id].isChecked() + self._driver.entry_state.set_tag(entry_ids, tag_id, is_checked) + + def _start_drag(self) -> None: + drag = QDrag(self._driver) + paths: list[QUrl] = [] + mimedata = QMimeData() + + selected_ids = self._driver.selected + + for entry_id in selected_ids: + entry = self._lib.get_entry(entry_id) + if not entry: + continue + + url = QUrl.fromLocalFile(Path(unwrap(self._lib.library_dir)) / entry.path) + paths.append(url) + + mimedata.setUrls(paths) + drag.setMimeData(mimedata) + drag.exec(Qt.DropAction.CopyAction) + logger.info("[ItemThumb] Dragging Files:", entry_ids=selected_ids) + + @override + def paintEvent(self, event: QPaintEvent) -> None: + if self._mode is None: + return + + painter = QPainter(self) + rect = self._image_rect() + if self._image is not None: + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.drawPixmap(rect, self._image) + + if self._image_hovered or self._selected: + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + width = 3 + path = QPainterPath() + path.addRoundedRect( + QRectF(width / 2, width / 2, rect.width() - width, rect.height() - width), + RADIUS, + RADIUS, + ) + + if self._selected: + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight) + painter.setPen(QPen(self._select_color_faded, width)) + painter.fillPath(path, self._select_color_faded) + painter.drawPath(path) + + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) + color: QColor = self._hover_color if self._image_hovered else self._select_color + painter.setPen(QPen(color, width)) + painter.drawPath(path) + else: + painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) + painter.setPen(QPen(self._hover_color, width)) + painter.drawPath(path) + + painter.end() + + @override + def enterEvent(self, event: QEnterEvent) -> None: + self._set_image_hovered(self._image_rect().contains(event.position().toPoint())) + return super().enterEvent(event) + + @override + def leaveEvent(self, event: QEvent) -> None: + self._set_image_hovered(hovered=False) + return super().leaveEvent(event) + + @override + def mousePressEvent(self, event: QMouseEvent) -> None: + pos = event.position().toPoint() + if event.button() != Qt.MouseButton.LeftButton or not self._image_rect().contains(pos): + return super().mousePressEvent(event) + + self._press_pos = pos + ctrl = QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier + self._select_on_release = False + if ctrl or not self._selected: + self._toggle_item_selection() + elif len(self._driver.selected) > 1: + # Wait for release so clicking a selected item can still drag the existing selection + self._select_on_release = True + + @override + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + if event.button() != Qt.MouseButton.LeftButton or self._press_pos is None: + return super().mouseReleaseEvent(event) + + self._press_pos = None + if self._select_on_release and self._image_rect().contains(event.position().toPoint()): + self._toggle_item_selection() + + @override + def mouseMoveEvent(self, event: QMouseEvent) -> None: + pos = event.position().toPoint() + self._set_image_hovered(self._image_rect().contains(pos)) + if self._press_pos is None or event.buttons() != Qt.MouseButton.LeftButton: + return + if (pos - self._press_pos).manhattanLength() < QApplication.startDragDistance(): + return + + self._press_pos = None + self._start_drag() + + @override + def contextMenuEvent(self, event: QContextMenuEvent) -> None: + if self._item_path is None or not self._image_rect().contains(event.pos()): + return super().contextMenuEvent(event) + + view = self.layout() + QMenu.exec( + [view.open_file_action, view.open_explorer_action, view.delete_action], + event.globalPos(), + None, + self, + ) diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index ef13a769ff..acee695663 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -42,10 +42,10 @@ from tagstudio.qt.controllers.banner_stack import BannerStack from tagstudio.qt.controllers.inspector import Inspector from tagstudio.qt.controllers.pagination import Pagination +from tagstudio.qt.controllers.thumb_grid import ThumbGrid from tagstudio.qt.helpers.mnemonics import assign_mnemonics from tagstudio.qt.mixed.landing import LandingWidget from tagstudio.qt.resource_manager import ResourceManager -from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, WIN_PAD, checkbox_style @@ -494,8 +494,7 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None: self.entry_list_layout: QVBoxLayout self.banners: BannerStack self.entry_scroll_area: QScrollArea - self.thumb_grid: QWidget - self.thumb_layout: ThumbGridLayout + self.thumb_grid: ThumbGrid self.landing_widget: LandingWidget self.pagination: Pagination @@ -700,18 +699,10 @@ def setup_entry_list(self, driver: QtDriver): self.entry_scroll_area.setFrameShadow(QFrame.Shadow.Plain) self.entry_scroll_area.setWidgetResizable(True) self.entry_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.entry_scroll_area.verticalScrollBar().valueChanged.connect( - lambda value: self.thumb_layout.update() - ) - self.thumb_grid = QWidget() - self.thumb_grid.setObjectName("thumb_grid") + self.thumb_grid = ThumbGrid(driver, self.entry_scroll_area) # Padding so floating pagination bar doesn't block bottom of scroll contents - self.thumb_layout = ThumbGridLayout( - driver, self.entry_scroll_area, bottom_padding=Pagination.HEIGHT - ) - self.thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.thumb_grid.setLayout(self.thumb_layout) + self.thumb_grid.setContentsMargins(0, 0, 0, Pagination.HEIGHT) self.entry_scroll_area.setWidget(self.thumb_grid) self.banners = BannerStack() diff --git a/src/tagstudio/qt/controllers/thumb_grid.py b/src/tagstudio/qt/controllers/thumb_grid.py new file mode 100644 index 0000000000..4589c7c04d --- /dev/null +++ b/src/tagstudio/qt/controllers/thumb_grid.py @@ -0,0 +1,349 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import time +from pathlib import Path +from typing import TYPE_CHECKING, override + +import structlog +from PySide6.QtCore import QSize, QThreadPool, QTimer, Signal +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QScrollArea, QWidget + +from tagstudio.core.library.alchemy.enums import ItemType +from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap +from tagstudio.previews.media_probe import can_have_duration, get_duration +from tagstudio.qt.controllers.item_thumb import ItemThumb +from tagstudio.qt.qt_file_renderer import QtFileRenderer +from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout + +if TYPE_CHECKING: + from tagstudio.qt.qt_driver import QtDriver + +logger = structlog.get_logger(__name__) + + +class ThumbGrid(QWidget): + """A scrollable grid of item thumbnails.""" + + DURATION_DELAY_MS = 200 # Allows for fast scrolling without getting lagged by duration labels + RESIZE_RENDER_DELAY_MS = 200 # Allows for more responsive rapid resizing + + visible_changed = Signal(int) # ID of the first visible entry + _entries_fetched = Signal(int, object, object) + _duration_loaded = Signal(Path, object) + + def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None: + super().__init__() + self.setObjectName("thumb_grid") + self._driver = driver + self._renderer = QtFileRenderer(driver.lib, driver.settings) + + self._duration_timer = QTimer(self) + self._duration_timer.setInterval(self.DURATION_DELAY_MS) + self._duration_timer.setSingleShot(True) + self._durations: dict[Path, float | None] = {} + self._entries: dict[int, Entry] = {} + self._entry_ids: list[int] = [] + self._entry_indices: dict[int, int] = {} + self._entry_paths: dict[Path, int] = {} + self._fetch_pool: QThreadPool | None = None + self._fetching: set[int] = set() + self._changed_while_fetching: set[int] = set() + self._generation = 0 + self._loading_image: QPixmap | None = None + self._pending_durations: set[Path] = set() + self._previous_size_renders: dict[Path, QPixmap] = {} + self._render_cutoff = 0.0 + self._renders: dict[Path, QPixmap | None] = {} + self._resize_render_timer = QTimer(self) + self._resize_render_timer.setInterval(self.RESIZE_RENDER_DELAY_MS) + self._resize_render_timer.setSingleShot(True) + self._scroll_to: int | None = None + + self.setLayout(ThumbGridLayout(scroll_area, self._create_thumb)) + self._connect_callbacks(scroll_area) + + @override + def layout(self) -> ThumbGridLayout[ItemThumb]: + return super().layout() # pyright: ignore[reportReturnType] + + def _connect_callbacks(self, scroll_area: QScrollArea) -> None: + scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self.layout().update()) + self.layout().items_assigned.connect(self._on_items_assigned) + self.layout().first_visible_changed.connect( + lambda index: self.visible_changed.emit(self._entry_ids[index]) + ) + self._renderer.updated.connect(self._on_rendered) + self._resize_render_timer.timeout.connect(self._request_renders) + self._duration_timer.timeout.connect(self._queue_durations) + self._entries_fetched.connect(self._on_entries_fetched) + self._duration_loaded.connect(self._on_duration_loaded) + self._driver.entry_state.tags_changed.connect(self._on_tags_changed) + + def set_entries(self, entry_ids: list[int]) -> None: + self._generation += 1 + self._resize_render_timer.stop() + self._entry_ids = entry_ids + self._entry_indices = {entry_id: index for index, entry_id in enumerate(entry_ids)} + self._entries.clear() + self._entry_paths.clear() + self._fetching.clear() + self._changed_while_fetching.clear() + self._driver.entry_state.unload_badges() + self._previous_size_renders.clear() + self._reset_renders() + self.layout().set_item_count(len(entry_ids)) + + index = None if self._scroll_to is None else self._entry_indices.get(self._scroll_to) + self._scroll_to = None + self.layout().scroll_to_index(index or 0) + + def scroll_to(self, entry_id: int) -> None: + """Scroll to this entry once the next entries are set.""" + self._scroll_to = entry_id + + def set_thumb_size(self, size: int) -> None: + self._previous_size_renders.update( + (path, image) for path, image in self._renders.items() if image is not None + ) + self._reset_renders() + self._resize_render_timer.start() + first_visible = self.layout().first_visible_index() + for thumb in self.layout().widgets(): + thumb.set_thumb_size(size) + if first_visible is not None: + self.layout().scroll_to_index(first_visible) + self.layout().activate() + + def set_filenames_visible(self, visible: bool) -> None: + for thumb in self.layout().widgets(): + thumb.set_filename_visibility(visible) + self.layout().activate() + + def update_selected(self) -> None: + for _, thumb in self.layout().assigned_items(): + thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage] + + def _create_thumb(self) -> ItemThumb: + size = self._driver.main_window.thumb_size + return ItemThumb( + ItemType.ENTRY, + self._driver.lib, + self._driver, + (size, size), + show_filename_label=self._driver.settings.show_filenames_in_grid, + ) + + def _path(self, entry: Entry) -> Path: + return unwrap(self._driver.lib.library_dir) / entry.path + + def _thumb_for(self, entry_id: int | None) -> ItemThumb | None: + index = None if entry_id is None else self._entry_indices.get(entry_id) + return None if index is None else self.layout().widget_at(index) + + def _on_items_assigned(self, items: list[tuple[int, ItemThumb]]) -> None: + missing: list[int] = [] + for index, thumb in items: + entry_id = self._entry_ids[index] + thumb.set_item_id(entry_id) + thumb.set_selected(entry_id in self._driver._selected) # pyright: ignore[reportPrivateUsage] + entry = self._entries.get(entry_id) + if entry is not None: + self._assign_entry(thumb, entry) + continue + + thumb.set_filename_text(Path()) + thumb.set_extension(Path()) + for tag_id in self._driver.entry_state.badge_tag_ids: + thumb.assign_badge(tag_id, value=False) + self._show_loading_image(thumb) + if entry_id not in self._fetching: + missing.append(entry_id) + + if missing: + self._fetch_entries(missing) + self._trim_render_queue() + self._request_renders() + + def _assign_entry(self, thumb: ItemThumb, entry: Entry) -> None: + path = self._path(entry) + thumb.set_item(entry) + thumb.set_filename_text(path) + thumb.set_extension(path) + thumb.set_duration(self._durations.get(path)) + self._assign_badges(thumb, entry.id) + + image = self._renders.get(path) or self._previous_size_renders.get(path) + if image is not None: + thumb.set_image(image, path) + elif thumb.rendered_path != path: + self._show_loading_image(thumb) + + def _show_loading_image(self, thumb: ItemThumb) -> None: + if self._loading_image is None: + thumb.clear_image() + else: + thumb.set_image(self._loading_image, Path()) + + def _assign_badges(self, thumb: ItemThumb, entry_id: int) -> None: + for tag_id in self._driver.entry_state.badge_tag_ids: + thumb.assign_badge(tag_id, self._driver.entry_state.has_badge(entry_id, tag_id)) + + def _on_tags_changed(self, entry_ids: set[int]) -> None: + self._changed_while_fetching.update(self._fetching.intersection(entry_ids)) + for _, thumb in self.layout().assigned_items(): + if thumb.item_id in entry_ids and thumb.item_id in self._entries: + self._assign_badges(thumb, thumb.item_id) + + def _fetch_entries(self, entry_ids: list[int]) -> None: + """Read entries and their badge tags on a worker thread.""" + self._fetching.update(entry_ids) + lib = self._driver.lib + generation = self._generation + badge_tag_ids = self._driver.entry_state.badge_tag_ids + + def fetch() -> None: + try: + entries = lib.get_entries(entry_ids) + tag_entries = lib.get_tag_entries(badge_tag_ids, entry_ids) + except Exception as e: + logger.error("[ThumbGrid] Error fetching entries", error=e) + return + self._entries_fetched.emit(generation, entries, tag_entries) + + if self._fetch_pool is None: + self._fetch_pool = QThreadPool(self) + self._fetch_pool.setMaxThreadCount(1) + self._fetch_pool.start(fetch) + + def _on_entries_fetched( + self, generation: int, entries: list[Entry], tag_entries: dict[int, set[int]] + ) -> None: + if generation != self._generation: + return + + entry_ids = [entry.id for entry in entries] + self._fetching.difference_update(entry_ids) + fresh = [e for e in entry_ids if e not in self._changed_while_fetching] + self._changed_while_fetching.difference_update(entry_ids) + self._driver.entry_state.cache_badges(fresh, tag_entries) + for entry in entries: + self._entries[entry.id] = entry + self._entry_paths[self._path(entry)] = entry.id + if (thumb := self._thumb_for(entry.id)) is not None: + self._assign_entry(thumb, entry) + self._request_renders() + + def _reset_renders(self) -> None: + self._render_cutoff = time.time() + self._renders.clear() + self._loading_image = None + self._driver.thumb_job_queue.queue.clear() + self._pending_durations.clear() + self._request_loading_image() + + def _request_loading_image(self) -> None: + size = self._driver.main_window.thumb_size + self._driver.thumb_job_queue.put( + ( + self._renderer.render, + ( + self._driver.cache_manager, + self._render_cutoff, + Path(), + (size, size), + self._driver.main_window.devicePixelRatio(), + True, + True, + ), + ) + ) + + def _request_renders(self) -> None: + """Queue renders for thumbnails, starting at the first visible one.""" + if self._resize_render_timer.isActive(): + return + + size = self._driver.main_window.thumb_size + ratio = self._driver.main_window.devicePixelRatio() + timestamp = time.time() + for entry in self._assigned_entries(): + path = self._path(entry) + if path in self._renders: + continue + self._renders[path] = None + self._driver.thumb_job_queue.put( + ( + self._renderer.render, + ( + self._driver.cache_manager, + timestamp, + path, + (size, size), + ratio, + False, + True, + entry.date_modified, + ), + ) + ) + self._duration_timer.start() + + def _assigned_entries(self) -> list[Entry]: + """Entries of the assigned thumbnails, starting at the first visible one.""" + first_visible = self.layout().first_visible_index() or 0 + assigned = sorted(self.layout().assigned_items(), key=lambda item: item[0] < first_visible) + return [entry for _, thumb in assigned if (entry := self._entries.get(thumb.item_id))] + + def _trim_render_queue(self) -> None: + """Trim queued renders once they get too backed up.""" + queue = self._driver.thumb_job_queue.queue + if len(queue) <= 2 * len(self.layout().assigned_items()): + return + + queue.clear() + self._pending_durations.clear() + self._renders = {path: image for path, image in self._renders.items() if image is not None} + if self._loading_image is None: + self._request_loading_image() + + def _on_rendered(self, timestamp: float, image: QPixmap, _size: QSize, file_path: Path) -> None: + if timestamp < self._render_cutoff: + return + + if file_path == Path(): + self._loading_image = image + for _, thumb in self.layout().assigned_items(): + entry = self._entries.get(thumb.item_id) + if entry is None or thumb.rendered_path != self._path(entry): + thumb.set_image(image, Path()) + return + + self._renders[file_path] = image + self._previous_size_renders.pop(file_path, None) + if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None: + thumb.set_image(image, file_path) + + def _queue_durations(self) -> None: + for entry in self._assigned_entries(): + path = self._path(entry) + if ( + not can_have_duration(path) + or path in self._durations + or path in self._pending_durations + ): + continue + self._pending_durations.add(path) + self._driver.thumb_job_queue.put((self._load_duration, (path,))) + + def _load_duration(self, file_path: Path) -> None: + self._duration_loaded.emit(file_path, get_duration(file_path)) + + def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None: + self._pending_durations.discard(file_path) + self._durations[file_path] = duration + if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None: + thumb.set_duration(duration) diff --git a/src/tagstudio/qt/controllers/tiles/tag_data.py b/src/tagstudio/qt/controllers/tiles/tag_data.py index 295697e5c9..a25bd23810 100644 --- a/src/tagstudio/qt/controllers/tiles/tag_data.py +++ b/src/tagstudio/qt/controllers/tiles/tag_data.py @@ -92,10 +92,7 @@ def _on_click(self, tag: Tag) -> None: def _on_remove(self, tag: Tag) -> None: logger.info("[TagData] remove_tag", selected=self._entries) - for entry_id in self._entries: - self._driver.lib.remove_tags_from_entries(entry_id, tag.id) - - self.on_update.emit() + self._driver.entry_state.remove_tags(self._entries, tag.id) def _on_edit(self, tag: Tag) -> None: edit_tag_panel = EditTagPanel(self._driver.lib, tag=tag) diff --git a/src/tagstudio/qt/controllers/tiles/tile_list.py b/src/tagstudio/qt/controllers/tiles/tile_list.py index 3c263030a9..b274ce7ba0 100644 --- a/src/tagstudio/qt/controllers/tiles/tile_list.py +++ b/src/tagstudio/qt/controllers/tiles/tile_list.py @@ -9,7 +9,6 @@ from typing import override import structlog -from PySide6.QtCore import Signal from PySide6.QtWidgets import QMessageBox, QWidget from tagstudio.core.library.alchemy.fields import ( @@ -39,8 +38,6 @@ class TileList(QWidget): """A styled list of Tile widgets.""" - on_tags_update = Signal() - def __init__(self, library: Library, driver: QtDriver) -> None: super().__init__() @@ -62,17 +59,15 @@ def top_entry_id(self) -> int: """Get the topmost entry ID in the (cached) selected entries.""" return self.cached_entries[0].id - def update_from_entry(self, entry_id: int, update_badges: bool = True) -> None: + def update_from_entry(self, entry_id: int) -> None: """Update tags and fields from a single Entry source.""" logger.warning("[TileList] Updating Selection", entry_id=entry_id) entry = unwrap(self.lib.get_entry_full(entry_id)) self.cached_entries = [entry] - self.update_granular(entry.tags, entry.fields, update_badges) + self.update_granular(entry.tags, entry.fields) - def update_granular( - self, entry_tags: set[Tag], entry_fields: list[BaseField], update_badges: bool = True - ) -> None: + def update_granular(self, entry_tags: set[Tag], entry_fields: list[BaseField]) -> None: """Individually update elements of the item preview.""" container_len: int = len(entry_fields) container_index = 0 @@ -83,8 +78,6 @@ def update_granular( self.write_tag_tile(container_index, tags=tags, category_tag=cat, is_mixed=False) container_index += 1 container_len += 1 - if update_badges: - self.driver.emit_badge_signals({t.id for t in entry_tags}) # Write field container(s) for index, field in enumerate(entry_fields, start=container_index): @@ -96,19 +89,6 @@ def update_granular( if i > (container_len - 1): c.setHidden(True) - def update_toggled_tag(self, tag_id: int, toggle_value: bool) -> None: - """Visually add or remove a tag from the item preview without needing to query the db.""" - entry = self.cached_entries[0] - tag = self.lib.get_tag(tag_id) - if not tag: - return - if toggle_value: - entry.tags.add(tag) - else: - entry.tags.discard(tag) - - self.update_granular(entry_tags=entry.tags, entry_fields=entry.fields, update_badges=False) - def hide_containers(self) -> None: """Hide all field and tag containers.""" for c in self._tiles: @@ -375,10 +355,7 @@ def write_tag_tile( inner_widget = TagData(Translations["entries.tags"], self.driver) container.set_inner_widget(inner_widget) inner_widget.on_update.connect( - lambda: ( - self.update_from_entry(self.cached_entries[0].id, update_badges=True), - self.on_tags_update.emit(), - ) + lambda: self.update_from_entry(self.cached_entries[0].id) ) inner_widget.set_entries([e.id for e in self.cached_entries]) inner_widget.set_tags(tags) diff --git a/src/tagstudio/qt/entry_state.py b/src/tagstudio/qt/entry_state.py new file mode 100644 index 0000000000..7ad0485599 --- /dev/null +++ b/src/tagstudio/qt/entry_state.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from collections.abc import Iterable + +import structlog +from PySide6.QtCore import QObject, Signal + +from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from tagstudio.core.library.alchemy.library import Library + +logger = structlog.get_logger(__name__) + + +def _id_set(ids: int | Iterable[int]) -> set[int]: + return {ids} if isinstance(ids, int) else set(ids) + + +class EntryState(QObject): + """Manages the state of attributes like tags for entries. + + Adding/removing tags to/from entries via the UI routes through here, and emits signals when tags + trigger relevant UI updates like item thumbnail badge updates. + """ + + tags_changed = Signal(set) + + def __init__(self, library: Library) -> None: + super().__init__() + self._lib = library + self._badge_tag_ids: tuple[int, ...] = (TAG_FAVORITE, TAG_ARCHIVED) + self._entries_with_badge: dict[int, set[int]] = { + tag_id: set() for tag_id in self._badge_tag_ids + } + self._badges_loaded: set[int] = set() + + @property + def badge_tag_ids(self) -> tuple[int, ...]: + return self._badge_tag_ids + + def load_badges(self, entry_ids: Iterable[int]) -> None: + """Read which entries have tags used for badges.""" + entry_ids = set(entry_ids) + if entry_ids: + tag_entries = self._lib.get_tag_entries(self._badge_tag_ids, list(entry_ids)) + self.cache_badges(entry_ids, tag_entries) + + def cache_badges(self, entry_ids: Iterable[int], tag_entries: dict[int, set[int]]) -> None: + """Store which entries have tags used for badges, as given by `tag_entries`.""" + entry_ids = set(entry_ids) + for tag_id, entries in self._entries_with_badge.items(): + entries.difference_update(entry_ids) + entries.update(entry_ids.intersection(tag_entries[tag_id])) + self._badges_loaded.update(entry_ids) + + def unload_badges(self) -> None: + for entries in self._entries_with_badge.values(): + entries.clear() + self._badges_loaded.clear() + + def has_badge(self, entry_id: int, tag_id: int) -> bool: + if tag_id not in self._entries_with_badge: + logger.warning("[EntryState] Tag is not used for badges", tag_id=tag_id) + return False + if entry_id not in self._badges_loaded: + self.load_badges([entry_id]) + return entry_id in self._entries_with_badge[tag_id] + + def set_tag(self, entry_ids: int | Iterable[int], tag_id: int, value: bool) -> None: + if value: + self.add_tags(entry_ids, tag_id) + else: + self.remove_tags(entry_ids, tag_id) + + def add_tags(self, entry_ids: int | Iterable[int], tag_ids: int | Iterable[int]) -> None: + """Add tags to one or more entries.""" + entry_ids_, tag_ids_ = _id_set(entry_ids), _id_set(tag_ids) + self._lib.add_tags_to_entries(entry_ids_, tag_ids_) + self._update(entry_ids_, tag_ids_, has_tag=True) + + def remove_tags(self, entry_ids: int | Iterable[int], tag_ids: int | Iterable[int]) -> None: + """Remove tags from one or more entries.""" + entry_ids_, tag_ids_ = _id_set(entry_ids), _id_set(tag_ids) + self._lib.remove_tags_from_entries(entry_ids_, tag_ids_) + self._update(entry_ids_, tag_ids_, has_tag=False) + + def _update(self, entry_ids: set[int], tag_ids: set[int], has_tag: bool) -> None: + for tag_id in tag_ids.intersection(self._badge_tag_ids): + if has_tag: + self._entries_with_badge[tag_id].update(entry_ids) + else: + self._entries_with_badge[tag_id].difference_update(entry_ids) + self.tags_changed.emit(entry_ids) diff --git a/src/tagstudio/qt/mixed/folders_to_tags.py b/src/tagstudio/qt/mixed/folders_to_tags.py index 47bfed84a2..bc4a2eb0c3 100644 --- a/src/tagstudio/qt/mixed/folders_to_tags.py +++ b/src/tagstudio/qt/mixed/folders_to_tags.py @@ -27,6 +27,7 @@ from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.capsule import Capsule +from tagstudio.qt.entry_state import EntryState from tagstudio.qt.views.layouts.flow_layout import FlowLayout from tagstudio.qt.views.styles.stylesheets import header @@ -57,7 +58,7 @@ def add_folders_to_tree(library: Library, tree: BranchData, items: tuple[str, .. @deprecated("Will be replaced with upcoming 'Macros' feature.") -def folders_to_tags(library: Library): +def folders_to_tags(library: Library, entry_state: EntryState): logger.info("Converting folders to Tags") tree = BranchData() @@ -79,7 +80,7 @@ def add_tag_to_tree(items: list[Tag]): tag = add_folders_to_tree(library, tree, folders).tag if tag and not entry.has_tag(tag): - library.add_tags_to_entries(entry.id, tag.id) + entry_state.add_tags(entry.id, tag.id) logger.info("Done") @@ -221,9 +222,8 @@ def __init__(self, library: Library, driver: QtDriver): self.root_layout.addWidget(self.apply_button, alignment=Qt.AlignmentFlag.AlignCenter) def on_apply(self): - folders_to_tags(self.library) + folders_to_tags(self.library, self.driver.entry_state) self.close() - self.driver.main_window.inspector.set_selection(self.driver.selected, update_preview=False) @override def showEvent(self, event: QtGui.QShowEvent): diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py deleted file mode 100644 index c2d0224db8..0000000000 --- a/src/tagstudio/qt/mixed/item_thumb.py +++ /dev/null @@ -1,535 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from enum import Enum -from functools import wraps -from pathlib import Path -from typing import TYPE_CHECKING, override - -import structlog -from PySide6.QtCore import QEvent, QMimeData, QSize, Qt, QUrl -from PySide6.QtGui import QAction, QDrag, QEnterEvent, QGuiApplication, QMouseEvent, QPixmap -from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget - -from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE -from tagstudio.core.library.alchemy.enums import ItemType -from tagstudio.core.library.alchemy.library import Library -from tagstudio.core.media_types import MediaTypes -from tagstudio.core.query_lang.file_groups import SEARCH -from tagstudio.core.utils.decompression import get_display_ext -from tagstudio.core.utils.str_formatting import format_duration -from tagstudio.core.utils.types import unwrap -from tagstudio.i18n.platform_strings import open_file_str, trash_term -from tagstudio.i18n.translations import Translations -from tagstudio.qt.utils.file_opener import FileOpenerHelper -from tagstudio.qt.views.layouts.flow_layout import FlowWidget -from tagstudio.qt.views.thumb_button import ThumbButton - -if TYPE_CHECKING: - from tagstudio.core.library.alchemy.models import Entry - from tagstudio.qt.qt_driver import QtDriver - -logger = structlog.get_logger(__name__) - - -class BadgeType(Enum): - FAVORITE = "Favorite" - ARCHIVED = "Archived" - - -BADGE_TAGS = { - BadgeType.FAVORITE: TAG_FAVORITE, - BadgeType.ARCHIVED: TAG_ARCHIVED, -} - - -def badge_update_lock(func): - """Prevent recursively triggering badge updates.""" - - @wraps(func) - def wrapper(self, *args, **kwargs): - if self.driver.badge_update_lock: - return - - self.driver.badge_update_lock = True - try: - func(self, *args, **kwargs) - except Exception: - raise - finally: - self.driver.badge_update_lock = False - - return wrapper - - -# TODO: Split to use MVC guidelines. -class ItemThumb(FlowWidget): - """The thumbnail widget for a library item (Entry, Entry Group, etc.).""" - - small_text_style = ( - "background-color:rgba(0, 0, 0, 192);" - "color:#FFFFFF;" - "font-family:Oxanium;" - "font-weight:bold;" - "font-size:12px;" - "border-radius:3px;" - "padding-top: 4px;" - "padding-right: 1px;" - "padding-bottom: 1px;" - "padding-left: 1px;" - ) - - med_text_style = ( - "background-color:rgba(0, 0, 0, 192);" - "color:#FFFFFF;" - "font-family:Oxanium;" - "font-weight:bold;" - "font-size:18px;" - "border-radius:3px;" - "padding-top: 4px;" - "padding-right: 1px;" - "padding-bottom: 1px;" - "padding-left: 1px;" - ) - - filename_style = "font-size:10px;" - - def __init__( - self, - mode: ItemType | None, - library: Library, - driver: QtDriver, - thumb_size: tuple[int, int], - show_filename_label: bool = False, - ): - super().__init__() - self.lib = library - self.mode: ItemType | None = mode - self.driver = driver - self.item_id: int = -1 - self.item_path: Path | None = None - self.rendered_path: Path | None = None - self._select_on_release: bool = False - self.thumb_size: tuple[int, int] = thumb_size - self.show_filename_label: bool = show_filename_label - self.label_height = 12 - self.label_spacing = 4 - self.setMinimumSize(*thumb_size) - self.setMaximumSize(*thumb_size) - self.setMouseTracking(True) - check_size = 24 - self.setFixedSize( - thumb_size[0], - thumb_size[1] - + ((self.label_height + self.label_spacing) if show_filename_label else 0), - ) - - self.thumb_container = QWidget() - self.base_layout = QVBoxLayout(self) - self.base_layout.setContentsMargins(0, 0, 0, 0) - self.base_layout.setSpacing(0) - self.setLayout(self.base_layout) - - # +----------+ - # | ARC FAV| Top Right: Favorite & Archived Badges - # | | - # | | - # |EXT #| Lower Left: File Type or Entry Group Icon - # +----------+ Lower Right: Entry Group Count, Video Length, or Word Count - # - # Filename Underneath: (Optional) Filename - - # Thumbnail ============================================================ - - # +----------+ - # |*--------*| - # || || - # || || - # |*--------*| - # +----------+ - self.thumb_layout = QVBoxLayout(self.thumb_container) - self.thumb_layout.setObjectName("baseLayout") - self.thumb_layout.setContentsMargins(0, 0, 0, 0) - - # +----------+ - # |[~~~~~~~~]| - # | | - # | | - # | | - # +----------+ - self.top_layout = QHBoxLayout() - self.top_layout.setObjectName("topLayout") - self.top_layout.setContentsMargins(6, 6, 6, 6) - self.top_container = QWidget() - self.top_container.setLayout(self.top_layout) - self.thumb_layout.addWidget(self.top_container) - - # +----------+ - # |[~~~~~~~~]| - # | ^ | - # | | | - # | v | - # +----------+ - self.thumb_layout.addStretch(2) - - # +----------+ - # |[~~~~~~~~]| - # | ^ | - # | v | - # |[~~~~~~~~]| - # +----------+ - self.bottom_layout = QHBoxLayout() - self.bottom_layout.setObjectName("bottomLayout") - self.bottom_layout.setContentsMargins(6, 6, 6, 6) - self.bottom_container = QWidget() - self.bottom_container.setLayout(self.bottom_layout) - self.thumb_layout.addWidget(self.bottom_container) - - self.thumb_button = ThumbButton(self.thumb_container, thumb_size) - self.thumb_button.setFlat(True) - self.thumb_button.setLayout(self.thumb_layout) - self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu) - - self.opener = FileOpenerHelper(Path()) - open_file_action = QAction(Translations["file.open_file"], self) - open_file_action.triggered.connect(self.opener.open_file) - open_explorer_action = QAction(open_file_str(), self) - open_explorer_action.triggered.connect(self.opener.open_explorer) - - self.delete_action = QAction( - Translations.format("trash.context.ambiguous", trash_term=trash_term()), - self, - ) - - def _on_delete(): - if self.item_id != -1 and self.item_path is not None: - self.driver.delete_files_callback(self.item_path, self.item_id) - - self.delete_action.triggered.connect(lambda checked=False: _on_delete()) - - self.thumb_button.addAction(open_file_action) - self.thumb_button.addAction(open_explorer_action) - self.thumb_button.addAction(self.delete_action) - - # Static Badges ======================================================== - - # Item Type Badge ------------------------------------------------------ - # Used for showing the Entry Group icons. - # Mutually exclusive with the File Extension Badge. - self.item_type_badge = QLabel() - self.item_type_badge.setObjectName("itemBadge") - # self.item_type_badge.setPixmap( - # QPixmap.fromImage( - # ImageQt.ImageQt( - # ItemThumb.collation_icon_128.resize( - # (check_size, check_size), Image.Resampling.BILINEAR - # ) - # ) - # ) - # ) - self.item_type_badge.setMinimumSize(check_size, check_size) - self.item_type_badge.setMaximumSize(check_size, check_size) - self.bottom_layout.addWidget(self.item_type_badge) - - # File Extension Badge ------------------------------------------------- - # Mutually exclusive with the File Extension Badge. - self.ext_badge = QLabel() - self.ext_badge.setObjectName("extBadge") - self.ext_badge.setStyleSheet(ItemThumb.small_text_style) - self.bottom_layout.addWidget(self.ext_badge) - self.bottom_layout.addStretch(2) - - # Count Badge ---------------------------------------------------------- - # Used for Entry Group counts, video length, word count, etc. - self.count_badge = QLabel() - self.count_badge.setObjectName("countBadge") - self.count_badge.setText("-:--") - self.count_badge.setStyleSheet(ItemThumb.small_text_style) - self.bottom_layout.addWidget(self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom) - self.top_layout.addStretch(2) - - # Intractable Badges =================================================== - self.cb_container = QWidget() - self.cb_layout = QHBoxLayout() - self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft) - self.cb_layout.setContentsMargins(0, 0, 0, 0) - self.cb_layout.setSpacing(6) - self.cb_container.setLayout(self.cb_layout) - self.top_layout.addWidget(self.cb_container) - - self.badge_active: dict[BadgeType, bool] = { - BadgeType.FAVORITE: False, - BadgeType.ARCHIVED: False, - } - - self.badges: dict[BadgeType, QCheckBox] = {} - badge_icons = { - BadgeType.FAVORITE: ( - ":/images/star_icon_empty_128.png", - ":/images/star_icon_filled_128.png", - ), - BadgeType.ARCHIVED: ( - ":/images/box_icon_empty_128.png", - ":/images/box_icon_filled_128.png", - ), - } - for badge_type in BadgeType: - icon_empty, icon_checked = badge_icons[badge_type] - badge = QCheckBox() - badge.setObjectName(badge_type.name) - badge.setToolTip(badge_type.value) - badge.setStyleSheet( - f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}" - f"QCheckBox::indicator::unchecked{{image: url({icon_empty})}}" - f"QCheckBox::indicator::checked{{image: url({icon_checked})}}" - ) - badge.setMinimumSize(check_size, check_size) - badge.setMaximumSize(check_size, check_size) - badge.setHidden(True) - - badge.stateChanged.connect(lambda x, bt=badge_type: self.on_badge_check(bt)) - - self.badges[badge_type] = badge - self.cb_layout.addWidget(badge) - - # Filename Label ======================================================= - self.file_label = QLabel(Translations["generic.filename"]) - self.file_label.setStyleSheet(ItemThumb.filename_style) - self.file_label.setMaximumHeight(self.label_height) - if not show_filename_label: - self.file_label.setHidden(True) - - self.base_layout.addWidget(self.thumb_container) - self.base_layout.addWidget(self.file_label) - # NOTE: self.item_id seems to act as a reference here and does not need to be updated inside - # QtDriver.update_thumbs() while item_thumb.delete_action does. - # If this behavior ever changes, move this method back to QtDriver.update_thumbs(). - self.thumb_button.pressed.connect(self._on_thumb_pressed) - self.thumb_button.clicked.connect(self._on_thumb_clicked) - self.set_mode(mode) - - @property - def is_favorite(self) -> bool: - return self.badge_active[BadgeType.FAVORITE] - - @property - def is_archived(self) -> bool: - return self.badge_active[BadgeType.ARCHIVED] - - def toggle_item_selection(self): - self.driver.toggle_item_selection( - self.item_id, - append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier), - bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier), - ) - - def _on_thumb_pressed(self) -> None: - ctrl = QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier - self._select_on_release = False - if ctrl or not self.thumb_button.selected: - self.toggle_item_selection() - elif len(self.driver.selected) > 1: - # Wait for release so clicking a selected item can still drag the existing selection - self._select_on_release = True - - def _on_thumb_clicked(self) -> None: - if self._select_on_release: - self.toggle_item_selection() - - def set_mode(self, mode: ItemType | None) -> None: - if mode is None: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True) - self.thumb_button.unsetCursor() - self.thumb_button.setHidden(True) - elif mode == ItemType.ENTRY: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) - self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.thumb_button.setHidden(False) - self.cb_container.setHidden(False) - # Count Badge depends on file extension (video length, word count) - self.item_type_badge.setHidden(True) - self.count_badge.setStyleSheet(ItemThumb.small_text_style) - self.count_badge.setHidden(True) - self.ext_badge.setHidden(True) - elif mode == ItemType.ENTRY_GROUP: - self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False) - self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor) - self.thumb_button.setHidden(False) - self.cb_container.setHidden(True) - self.ext_badge.setHidden(True) - self.count_badge.setStyleSheet(ItemThumb.med_text_style) - self.count_badge.setHidden(False) - self.item_type_badge.setHidden(False) - self.mode = mode - - def set_extension(self, filename: Path) -> None: - show_ext_badge = False - show_count_badge = False - - ext = get_display_ext(filename) - if ext and ext.startswith(".") is False: - ext = "." + ext - if ( - not MediaTypes.contains("image.raster", ext, SEARCH) - or MediaTypes.contains("image.raster.raw", ext, SEARCH) - or MediaTypes.contains("image.vector", ext, SEARCH) - or MediaTypes.contains("adobe.photoshop", ext, SEARCH) - or ext - in [ - ".apng", - ".avif", - ".exr", - ".gif", - ".jxl", - ".webp", - ] - ): - if ext or filename.stem: - self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper()) - show_ext_badge = True - if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains( - "audio", ext, SEARCH - ): - show_count_badge = True - - self.ext_badge.setHidden(not show_ext_badge) - self.count_badge.setHidden(not show_count_badge) - - def set_count(self, count: str) -> None: - if count: - self.count_badge.setHidden(False) - self.count_badge.setText(count) - else: - if self.mode == ItemType.ENTRY: - self.ext_badge.setHidden(True) - self.count_badge.setHidden(True) - - def set_duration(self, duration: float | None) -> None: - """Show a media duration on the count badge, or "-:--" while it's unknown.""" - if duration is None: - self.count_badge.setText("-:--") - else: - self.count_badge.setText(format_duration(duration)) - self.count_badge.setHidden(False) - - def set_filename_text(self, filename: Path): - self.file_label.setText(str(filename.name)) - - def set_filename_visibility(self, set_visible: bool): - """Toggle the visibility of the filename label. - - Args: - set_visible (bool): Show the filename, true or false. - """ - if set_visible: - if self.file_label.isHidden(): - self.file_label.setHidden(False) - self.setFixedHeight(self.thumb_size[1] + self.label_height + self.label_spacing) - else: - self.file_label.setHidden(True) - self.setFixedHeight(self.thumb_size[1]) - self.show_filename_label = set_visible - - def update_thumb(self, image: QPixmap | None = None, file_path: Path | None = None): - """Update attributes of a thumbnail element.""" - self.thumb_button.setIcon(image if image else QPixmap()) - self.rendered_path = file_path - - def update_size(self, size: QSize): - """Updates attributes of a thumbnail element. - - Args: - size (QSize): The new thumbnail size to set. - """ - self.thumb_size = size.width(), size.height() - self.thumb_button.setIconSize(size) - self.thumb_button.setMinimumSize(size) - self.thumb_button.setMaximumSize(size) - - def set_item(self, entry: Entry): - self.set_item_id(entry.id) - path = unwrap(self.lib.library_dir) / entry.path - self.set_item_path(path) - - def set_item_id(self, item_id: int): - self.item_id = item_id - - def set_item_path(self, path: Path): - """Set the absolute filepath for the item. Used for locating on disk.""" - self.item_path = path - self.opener.set_filepath(path) - - def assign_badge(self, badge_type: BadgeType, value: bool) -> None: - mode = self.mode - # blank mode to avoid recursive badge updates - badge = self.badges[badge_type] - self.badge_active[badge_type] = value - if badge.isChecked() != value: - self.mode = None - badge.setChecked(value) - badge.setHidden(not value) - self.mode = mode - - def show_check_badges(self, show: bool): - if self.mode != ItemType.TAG_GROUP: - for badge_type, badge in self.badges.items(): - is_hidden = not (show or self.badge_active[badge_type]) - badge.setHidden(is_hidden) - - @override - def enterEvent(self, event: QEnterEvent) -> None: - self.show_check_badges(show=True) - return super().enterEvent(event) - - @override - def leaveEvent(self, event: QEvent) -> None: - self.show_check_badges(show=False) - return super().leaveEvent(event) - - @badge_update_lock - def on_badge_check(self, badge_type: BadgeType): - if self.mode is None: - return - - toggle_value = self.badges[badge_type].isChecked() - self.badge_active[badge_type] = toggle_value - badge_values: dict[BadgeType, bool] = {badge_type: toggle_value} - # TODO: Ensure that self.item_id is always an integer. During tests, it is currently None. - # This issue should be addressed by either fixing the test setup or modifying the - # self.driver.update_badges() method. - self.driver.update_badges(badge_values, self.item_id) - - def toggle_item_tag( - self, - entry_id: int, - toggle_value: bool, - tag_id: int, - ): - selected = self.driver._selected - if len(selected) == 1 and entry_id in selected: - self.driver.main_window.inspector.containers.update_toggled_tag(tag_id, toggle_value) - - @override - def mouseMoveEvent(self, event: QMouseEvent) -> None: - if event.buttons() is not Qt.MouseButton.LeftButton: - return - - drag = QDrag(self.driver) - paths: list[QUrl] = [] - mimedata = QMimeData() - - selected_ids = self.driver.selected - - for entry_id in selected_ids: - entry = self.lib.get_entry(entry_id) - if not entry: - continue - - url = QUrl.fromLocalFile(Path(unwrap(self.lib.library_dir)) / entry.path) - paths.append(url) - - mimedata.setUrls(paths) - drag.setMimeData(mimedata) - drag.exec(Qt.DropAction.CopyAction) - logger.info("[ItemThumb] Dragging Files:", entry_ids=selected_ids) diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index eb66738d16..487bf1e3af 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -127,7 +127,7 @@ def setup_color_groups(self): () if len(self.driver.selected) < 1 else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501 - self.driver.selected[0], update_badges=False + self.driver.selected[0] ), ) ) @@ -144,7 +144,7 @@ def setup_color_groups(self): () if len(self.driver.selected) < 1 else self.driver.main_window.inspector.containers.update_from_entry( # noqa: E501 - self.driver.selected[0], update_badges=False + self.driver.selected[0] ), ), ) diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index 28d80bb585..34b81c51f8 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -41,8 +41,7 @@ ) from PySide6.QtWidgets import QApplication, QFileDialog, QMessageBox, QPushButton, QScrollArea -import tagstudio.qt.resources_rc # noqa: F401 # pyright: ignore[reportUnusedImport] -from tagstudio.core.constants import BUILD_TYPE, TAG_ARCHIVED, TAG_FAVORITE, VERSION +from tagstudio.core.constants import BUILD_TYPE, VERSION from tagstudio.core.driver import DriverMixin from tagstudio.core.enums import AppCacheItems, MacroID, ShowFilepathOption from tagstudio.core.library.alchemy.enums import BrowsingState, SortingModeEnum @@ -75,12 +74,12 @@ from tagstudio.qt.controllers.sync_banner import SyncBanner from tagstudio.qt.controllers.tag_search_panel import TagSearchPanel from tagstudio.qt.controllers.update_available_message_box import UpdateAvailableMessageBox +from tagstudio.qt.entry_state import EntryState from tagstudio.qt.mixed.about_modal import AboutModal from tagstudio.qt.mixed.drop_import_modal import DropImportModal from tagstudio.qt.mixed.fix_dupe_files import FixDupeFilesModal from tagstudio.qt.mixed.fix_unlinked import FixUnlinkedEntriesModal from tagstudio.qt.mixed.folders_to_tags import FoldersToTagsModal -from tagstudio.qt.mixed.item_thumb import BadgeType from tagstudio.qt.mixed.migration_modal import JsonMigrationModal from tagstudio.qt.mixed.settings_panel import SettingsPanel from tagstudio.qt.mixed.tag_color_manager import TagColorManager @@ -92,12 +91,6 @@ from tagstudio.qt.views.styles.palette import ColorType, Palette, UiColor, get_ui_color from tagstudio.qt.views.styles.stylesheets import header -BADGE_TAGS = { - BadgeType.FAVORITE: TAG_FAVORITE, - BadgeType.ARCHIVED: TAG_ARCHIVED, -} - - # SIGQUIT is not defined on Windows if sys.platform == "win32": from signal import SIGINT, SIGTERM, signal @@ -149,12 +142,19 @@ def erase_future(self) -> None: self.__history = self.__history[: self.__index + 1] def push(self, value: T) -> None: + """Make `value` current and drop any forward history, unless it equals the current one.""" + if value == self.current: + return self.erase_future() self.__history.append(value) self.__index = len(self.__history) - 1 - def move(self, delta: int): - self.__index = clamp(self.__index + delta, 0, len(self.__history) - 1) + def move(self, delta: int) -> bool: + """Move through the history, returning whether the current value changed.""" + index = clamp(self.__index + delta, 0, len(self.__history) - 1) + moved = index != self.__index + self.__index = index + return moved @property def current(self) -> T: @@ -166,11 +166,6 @@ class QtDriver(DriverMixin, QObject): SIGTERM = Signal() - # TODO: If these signals are removed from the base QtDriver, we can probably get rid of the - # QObject multiple inheritance and make DriverMixin a proper BaseDriver class. - favorite_updated = Signal(bool) - archived_updated = Signal(bool) - tag_manager: Modal | None = None color_manager_panel: TagColorManager | None = None field_template_manager: Modal | None = None @@ -195,11 +190,11 @@ def __init__(self, args: Namespace): super().__init__() self.args = args self.lib = Library() + self.entry_state = EntryState(self.lib) self.rm: ResourceManager = ResourceManager() # Thumbnail Grid self._selected: OrderedDict[int, None] = OrderedDict() - self.badge_update_lock = False self.frame_content: list[int] = [] # List of Entry IDs for the current query self.scrollbar_pos = 0 self.spacing = None @@ -423,10 +418,7 @@ def start(self) -> None: add_tag_tsp.search_for_tag.connect(self.search_for_tag_callback) self.add_tag_modal = Modal(add_tag_tsp, Translations["tag.add.plural"]) add_tag_tsp.item_chosen.connect( - lambda chosen_tag: ( - self.add_tags_to_selected_callback([chosen_tag]), - self.main_window.inspector.set_selection(self.selected, update_preview=False), - ) + lambda chosen_tag: self.add_tags_to_selected_callback([chosen_tag]) ) # region Menu Bar @@ -613,26 +605,10 @@ def create_about_modal(): self.main_window.search_field.textChanged.connect(self.update_completions_list) - def on_visible_changed(entry_id: int | None): - current = self.browsing_history.current - page_index = current.page_index - if entry_id is None: - current.page_positions.pop(page_index) - else: - current.page_positions[page_index] = entry_id + def on_visible_changed(entry_id: int): + self.browsing_history.current.first_visible_entry_id = entry_id - self.main_window.thumb_layout.visible_changed.connect(on_visible_changed) - - self.archived_updated.connect( - lambda hidden: self.update_badges( - {BadgeType.ARCHIVED: hidden}, origin_id=0, add_tags=False - ) - ) - self.favorite_updated.connect( - lambda hidden: self.update_badges( - {BadgeType.FAVORITE: hidden}, origin_id=0, add_tags=False - ) - ) + self.main_window.thumb_grid.visible_changed.connect(on_visible_changed) QFontDatabase.addApplicationFont( str(Path(__file__).parents[1] / "resources/qt/fonts/Oxanium-Bold.ttf") @@ -772,8 +748,7 @@ def init_ignore_modal(self): self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show) def show_grid_filenames(self, value: bool): - for thumb in self.main_window.thumb_layout._item_thumbs: - thumb.set_filename_visibility(value) + self.main_window.thumb_grid.set_filenames_visible(value) def call_if_library_open(self, func): """Check if loaded library has valid path before executing the button function.""" @@ -844,7 +819,7 @@ def close_library(self, is_shutdown: bool = False): if hasattr(self, "library_info_window"): self.library_info_window.close() - self.main_window.thumb_layout.set_entries([]) + self.main_window.thumb_grid.set_entries([]) self.main_window.inspector.set_selection(self.selected) self.main_window.toggle_landing_page(enabled=True) self.main_window.landing_widget.animate_logo_in() @@ -877,19 +852,6 @@ def backup_library(self): logger.info("Backing Up Library...") Library.save_library_backup_to_disk(unwrap(self.lib.library_dir)) - def emit_badge_signals(self, tag_ids: list[int] | set[int], emit_on_absent: bool = True): - """Emit any connected signals for updating badge icons.""" - logger.info("[emit_badge_signals] Emitting", tag_ids=tag_ids, emit_on_absent=emit_on_absent) - if TAG_ARCHIVED in tag_ids: - self.archived_updated.emit(True) # noqa: FBT003 - elif emit_on_absent: - self.archived_updated.emit(False) # noqa: FBT003 - - if TAG_FAVORITE in tag_ids: - self.favorite_updated.emit(True) # noqa: FBT003 - elif emit_on_absent: - self.favorite_updated.emit(False) # noqa: FBT003 - def add_tag_action_callback(self): panel = EditTagPanel(self.lib) self.modal = Modal( @@ -943,10 +905,7 @@ def clear_select_action_callback(self): self.main_window.inspector.set_selection(self.selected) def add_tags_to_selected_callback(self, tag_ids: list[int]): - selected: list[int] = self.selected - self.main_window.thumb_layout.add_tags(selected, tag_ids) - self.lib.add_tags_to_entries(selected, tag_ids) - self.emit_badge_signals(tag_ids, emit_on_absent=False) + self.entry_state.add_tags(self.selected, tag_ids) def delete_files_callback(self, origin_path: str | Path, origin_id: int | None = None): """Callback to send on or more files to the system trash. @@ -1308,15 +1267,7 @@ def sorting_mode_callback(self): def thumb_size_callback(self, size: int): """Perform actions needed when the thumbnail size selection is changed.""" - self.update_thumbs() - blank_icon: QIcon = QIcon() - for it in self.main_window.thumb_layout._item_thumbs: - it.thumb_button.setIcon(blank_icon) - it.resize(self.main_window.thumb_size, self.main_window.thumb_size) - it.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size) - it.setFixedSize(self.main_window.thumb_size, self.main_window.thumb_size) - it.thumb_button.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size) - it.set_filename_visibility(it.show_filename_label) + self.main_window.thumb_grid.set_thumb_size(self.main_window.thumb_size) def show_hidden_entries_callback(self): logger.info("Show Hidden Entries Changed", exclude=self.main_window.show_hidden_entries) @@ -1337,16 +1288,9 @@ def page_move(self, value: int, absolute=False) -> None: logger.info("page_move", value=value, absolute=absolute) current = self.browsing_history.current - if not absolute: - current.page_index += value - else: - current.page_index = value - current.page_index = clamp(current.page_index, 0, self.pages_count - 1) - - # TODO: The back mouse button will no longer move to the previous page and - # instead goto the previous query passing a new state to update_browsing_state - # will get this behaviour back but would mess with persisting page scroll positions - self.update_browsing_state() + index = clamp(value if absolute else current.page_index + value, 0, self.pages_count - 1) + if index != current.page_index: + self.update_browsing_state(current.with_page_index(index)) def navigation_callback(self, delta: int) -> None: """Callback for the Forwads and Backwards Navigation Buttons next to the search bar.""" @@ -1355,9 +1299,8 @@ def navigation_callback(self, delta: int) -> None: delta=delta, ) - self.browsing_history.move(delta) - - self.update_browsing_state() + if self.browsing_history.move(delta): + self.update_browsing_state() def copy_fields_action_callback(self): if len(self.selected) > 0: @@ -1380,14 +1323,10 @@ def paste_fields_action_callback(self): exists = True if not exists: self.lib.add_field_to_entries(id, field=field) - self.lib.add_tags_to_entries(id, self.copy_buffer["tags"]) - if len(self.selected) > 1: - if TAG_ARCHIVED in self.copy_buffer["tags"]: - self.update_badges({BadgeType.ARCHIVED: True}, origin_id=0, add_tags=False) - if TAG_FAVORITE in self.copy_buffer["tags"]: - self.update_badges({BadgeType.FAVORITE: True}, origin_id=0, add_tags=False) - else: - self.main_window.inspector.set_selection(self.selected) + if self.copy_buffer["tags"]: + self.entry_state.add_tags(self.selected, self.copy_buffer["tags"]) + elif len(self.selected) == 1: + self.main_window.inspector.containers.update_from_entry(self.selected[0]) def toggle_item_selection(self, item_id: int, append: bool, bridge: bool): """Toggle the selection of an item in the Thumbnail Grid. @@ -1522,72 +1461,26 @@ def update_thumbs(self): start = page * page_size end = min(start + page_size, len(self.frame_content)) - self.main_window.thumb_layout.set_entries(self.frame_content[start:end]) - self.main_window.thumb_layout.update() + self.main_window.thumb_grid.set_entries(self.frame_content[start:end]) self.main_window.update() - def update_badges(self, badge_values: dict[BadgeType, bool], origin_id: int, add_tags=True): - """Update the tag badges for item_thumbs. - - Args: - badge_values(dict[BadgeType, bool]): The BadgeType and associated viability state. - origin_id(int): The ID of the item_thumb calling this method. If the ID is found as a - part of the current selection, or if the ID is 0, the the entire current selection - will be updated. Otherwise, only item_thumbs with that ID will be updated. - add_tags(bool): Flag determining if tags associated with the badges need to be added to - the items. Defaults to True. - """ - entry_ids = ( - set(self._selected.keys()) - if (origin_id == 0 or origin_id in self._selected) - else {origin_id} - ) - - logger.info( - "[QtDriver][update_badges] Updating ItemThumb badges", - badge_values=badge_values, - origin_id=origin_id, - add_tags=add_tags, - ) - for it in self.main_window.thumb_layout._item_thumbs: - if it.item_id in entry_ids: - for badge_type, value in badge_values.items(): - if add_tags: - it.toggle_item_tag(it.item_id, value, BADGE_TAGS[badge_type]) - it.assign_badge(badge_type, value) - - if not add_tags: - return - - logger.info( - "[QtDriver][update_badges] Adding tags to updated entries", - pending_entries=entry_ids, - ) - for badge_type, value in badge_values.items(): - tag_ids = [BADGE_TAGS[badge_type]] - - if value: - self.main_window.thumb_layout.add_tags(entry_ids, tag_ids) - self.lib.add_tags_to_entries(entry_ids, tag_ids) - else: - self.main_window.thumb_layout.remove_tags(entry_ids, tag_ids) - self.lib.remove_tags_from_entries(entry_ids, tag_ids) - def update_browsing_state(self, state: BrowsingState | None = None) -> None: """Navigates to a new BrowsingState when state is given, otherwise updates the results.""" if not self.lib.library_dir: logger.info("Library not loaded") return + previous = self.browsing_history.current if state: self.browsing_history.push(state) + current = self.browsing_history.current - self.main_window.search_field.setText(self.browsing_history.current.query or "") + self.main_window.search_field.setText(current.query or "") # search the library start_time = time.time() Ignore.get_patterns(self.lib.library_dir, include_global=True) - results = self.lib.search_library(self.browsing_history.current, page_size=0) + results = self.lib.search_library(current, page_size=0) logger.info("items to render", count=len(results)) end_time = time.time() @@ -1597,7 +1490,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: self.main_window.results_label.setText( Translations.format( # Show "X Results" on query, "X Items" on none - "status.results_found" if state and state.query else "status.items", + "status.results_found" if current.query else "status.items", count=count, time_span=time_span, ) @@ -1605,13 +1498,16 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None: # update page content self.frame_content = results.ids - page_index = self.browsing_history.current.page_index + same_page = current.query == previous.query and current.page_index == previous.page_index if state is None: - entry_id = self.browsing_history.current.page_positions.get(page_index) - else: + entry_id = current.first_visible_entry_id + elif same_page and current is not previous: + # Keep the selection in view when the same results are only resorted or filtered entry_id = self.last_selected + else: + entry_id = None if entry_id is not None: - self.main_window.thumb_layout.scroll_to(entry_id) + self.main_window.thumb_grid.scroll_to(entry_id) self.update_thumbs() # update pagination @@ -1888,7 +1784,7 @@ def drag_move_event(self, event: QDragMoveEvent): def select_all(self): self._selected = OrderedDict.fromkeys(self.frame_content) - self.main_window.thumb_layout.update_selected() + self.main_window.thumb_grid.update_selected() def select_inverse(self): selected = OrderedDict() @@ -1897,14 +1793,14 @@ def select_inverse(self): selected[id] = None self._selected = selected - self.main_window.thumb_layout.update_selected() + self.main_window.thumb_grid.update_selected() def select_entry(self, entry_id: int): if entry_id in self._selected: self._selected.pop(entry_id) else: self._selected[entry_id] = None - self.main_window.thumb_layout.update_selected() + self.main_window.thumb_grid.update_selected() def select_to_entry(self, entry_id: int): if len(self._selected) == 0: @@ -1922,11 +1818,11 @@ def select_to_entry(self, entry_id: int): for i in range(start, end): entry_id = self.frame_content[i] self._selected[entry_id] = None - self.main_window.thumb_layout.update_selected() + self.main_window.thumb_grid.update_selected() def clear_selected(self): self._selected.clear() - self.main_window.thumb_layout.update_selected() + self.main_window.thumb_grid.update_selected() def log_optional_modules(self) -> None: """Logs the status of optional modules.""" diff --git a/src/tagstudio/qt/resources.json b/src/tagstudio/qt/resources.json index edf51e7fbc..751b8a44a7 100644 --- a/src/tagstudio/qt/resources.json +++ b/src/tagstudio/qt/resources.json @@ -35,6 +35,14 @@ "mode": "pil", "path": "qt/images/file_icons/audio.png" }, + "box_icon_empty": { + "mode": "qpixmap", + "path": "qt/images/box_icon_empty_128.png" + }, + "box_icon_filled": { + "mode": "qpixmap", + "path": "qt/images/box_icon_filled_128.png" + }, "broken_link_icon": { "mode": "pil", "path": "qt/images/broken_link_icon.png" @@ -191,6 +199,14 @@ "mode": "pil", "path": "qt/images/file_icons/text.png" }, + "star_icon_empty": { + "mode": "qpixmap", + "path": "qt/images/star_icon_empty_128.png" + }, + "star_icon_filled": { + "mode": "qpixmap", + "path": "qt/images/star_icon_filled_128.png" + }, "thumb_loading": { "mode": "pil", "path": "qt/images/thumb_loading.png" diff --git a/src/tagstudio/qt/resources.qrc b/src/tagstudio/qt/resources.qrc deleted file mode 100644 index 3d5b5ab2f3..0000000000 --- a/src/tagstudio/qt/resources.qrc +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - ../../resources/qt/images/star_icon_empty_128.png - ../../resources/qt/images/star_icon_filled_128.png - ../../resources/qt/images/box_icon_empty_128.png - ../../resources/qt/images/box_icon_filled_128.png - - diff --git a/src/tagstudio/qt/resources_rc.py b/src/tagstudio/qt/resources_rc.py deleted file mode 100644 index fc5143759c..0000000000 --- a/src/tagstudio/qt/resources_rc.py +++ /dev/null @@ -1,1566 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -# Resource object code (Python 3) -# Created by: object code -# Created by: The Resource Compiler for Qt version 6.8.0 -# WARNING! All changes made in this file will be lost! - -from PySide6 import QtCore - -qt_resource_data = b"\ -\x00\x00\x22\x7f\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\ -\x00\x00\x05\xc9iTXtXML:com.\ -adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \ - \x0a \ - \x0a \x0a \x0a \ - \x0a <\ -/xmpMM:History>\x0a\ - \x0a \ -\x0a\x0aB\x02\x0c\xf5\x00\x00\x01\x81iCCPsRG\ -B IEC61966-2.1\x00\x00\ -(\x91u\x91\xcbKBA\x14\x87\xbf\xb4\xb2'\x06E\ -\xb4h!Q\xad4\xac@j\xd3B\xe9\x05\xd5B\x0d\ -\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\x06m\x83\x82\xa8\ -M\xafE\xfd\x05\xb5\x0dZ\x07AQ\x04\xd1\xb6\xd6E\ -mJn\xe7j\xa0D\xce0\xe7|\xf3\x9bs\x0e3\ -g\xc0\x12L)i\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\ -\x0b\xa1E\x87\xed\x85F:\xa9\x17k\x09+\xba:\x1b\ -\x98\x08Ru|\xdeSc\xfa[\x97Y\xabz\xdc\xbf\ -\xa39\x1a\xd3\x15\xa8i\x10\x1eST-+<%<\ -\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\ -\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\xfcm\xb2\x16\xf4\ -\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\xa4\x96\x16\x96\x97\ -\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89e\xe6\x03\xe2{\ -du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\x87\x87AF\xc5\ -zp1\xc4\x80\xec\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8a\ -X\x95<\x1a+$H\x92\xc5)jN\xaa\xc7\xc4\xc7\ -E\x8f\xc9L\x917\xfb\xff\xb7\xafz|x\xa8T\xbd\ -\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(l\x19\xc6\xd7\x91\ -a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea!\x8c|\x88\xbe\ -U\xd6z\x0f\xc0\xbe\x01\xe7We-\xb2\x0b\x17\x9b\xd0\ -\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!\ -h\xbf\x81\xa6\xa5R\xcf~\xcf9y\x80\xe0\xba|\xd5\ -5\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00\x99\x1d\x0b-\xff\x9d}W8\xfaN\xa9\ -1\xfb\x0f\xf2\x05\xf5K\x9d\xa9\xe1\xc6c\xcb\x12\x80\xab\ -\xf4\xd5\xf0)|w\xdd>\xc0'\x9e\x0e\xef|\x0f\xdf\ -{~\x967\x8f.\xf9\xb3L \xb9U\x95\xc2\xad\xac\ -\x03\xfcw|\x9d?2,\xaf\xd8\xf9\x00\x9fxz\x8c\ -\x91\xe1\x80\xc4\x90\x80\xff\xd6\xf6\xdam\x12le\x02\x08\ -\xb0~O\xde\xb7\x82\x90\xb2\xcf\xb6\xa1>\x9b\x12[\x92\ -\x00r\xd9L\x02\xd8\xee\xa5%I`\xf2\xe1\x91\x96\xef\ -\x9f|x\x04I\x0aH\xc7\xed\xee3\xb7\x1c\xb6$\x01\ -\x00\xff\xd3\x9f\x18\x1a\x5c\xfdd'\xe4\x9e\xff\xb1\x8e\xfa\ -lZlU\x02\xf8i\x7f\xe2\xb6[\xfaV\xfd\x80\x90\ -{~f\xed\xd5\xd9\xbc\xd8r\x04\x90\xcbf\xd2\xc0\xb8\ -\x97\x96e\x81\xc7\x1ej\x9d\xfd{x\xec\xa1\x11d9\ - \x06\xc6\xddgo)l9\x02\x00\xf2\xfe\xc4Z\xd8\ -\x7f\xcc\xbd\xbf\xbe\xe6\x87mRlE\x02\xf87\xfe\xc4\ -\x9e\x18\xed?9 \x93\x1c\x88&\x90\x90{?\xb7\x9e\ -\x8amFl)\x02\xc8e3\x03@\xc6K'\x14\x91\ -G\x1e\x1c\x8e,\x9f\x1a\x92I\x0dE\x13\xc0#\x0f\x0e\ -\x93P\x02M\x94q\xdf\xb1e\xb0\xa5\x08\x00\xf8-\x7f\ -bh0\xca\xeb\x0b\x82\x00\xc9~\x89d\xbf\x84\x10c\ -\x0f\x0dy\xc6W\xd6Q\xbfM\x87\xadF\x00\xff\xca\x9f\ -\x883\xfe$\x07d\x04Q@\x10\x85X1\x10\xf2\x8c\ -\x9f\x5cO\x057\x1b\xb6\x0c\x01\xe4\xb2\x99a\xa0\xce\xef\ -\x93I\x91\x87\x1f\x88a\xff>\x05/\x19\xa3(>\xfc\ -\xc00\xc9d\xa0\x99F\xdcwm\x09l\x19\x02\x00\xbe\ -\xe6O\xc4i\xff\x82(\x90\xf0\x8d\xfad\xbf\xc3\x0d\xa2\ -\x10\xf2\xac\xaf\xae\xa9\x86\x9b\x10[\x89\x00~\xc2\x9f\x88\ -g\xffA\xb9/\x88N^\x14B\x9e\xf5\xe9\xb5Tp\ -3bK\x10@.\x9b\xd9\x06\x0cy\xe9\xbe\xb4\xc4C\ -\xf7\x0dE\x96O\x85p\x87\xb0<\x0f\x0f\xdd7D*\ -\x15h\xaa\xa1\x5c6\x93\x89*\xdfK\xd8\x12\x04@\x13\ -\xfb\x8f\xd1\xfeE\x81D\x7fsg'V/\x06~o\ -U5\xdc\xa4\xd8*\x04\xf0)\x7fb5\xec\xdf\x83 \ -\xc4\x8b\x81\x10\xa3\xd0\xa7\xc2\xca\xf5\x1az\x9e\x00r\xd9\ -\xcc\x0e\xa0\xde;\x03\xfd\x12\xf7\xdf\x13\xc3\xfe\x87\x945\ -]{\xe0\xde!\xfa\xfb\x02\x042\x90\xcbf\xb6G\x95\ -\xef\x15\xf4<\x01\x00\xbf\xefO\xc4i\xff\xa2$\x90\xe8\ -\x8b\x1e\xe5\x89>\x09QZ\x95\x18\xf8zK5\xdc\xc4\ -\xd8\x0a\x04\xf0\x8c?\xb1\xa2\xf1'\xc6\xea\xe7\x88\x81U\ -\x19\x85~\xa8\x95\x0anf\xf44\x01\xe4\xb2\x99]@\ -\xddq?4(s\xefG\x07#\xcb\xc7i\xfa\xad\x94\ -\xb9\xef\xeeA\x06\x83\xd7\xfbr\xd9\xcc\x0d-Tu\xd3\ -\xa2\xa7\x09\x80\x06M|8\xc6\xb1\xb3\x12\xfb\xf7\xb0\xb2\ -\x18hzFO\x8b\x81^'\x80\xa7\xfd\x89X\xf6?\ -(\xb7\xb6\x08^\x887\x0d\x87\xcc\x06\x9e\x0e+\xd7+\ -\xe8Y\x02p\xb7m\xa5\xbc\xf4\xe8\x88\xc2G\xef\x88\xf6\ -\xd4\xb6\xc2\xfe[){\xf7\x9d\x83\x8c\x8e\x04f\x0b\xe9\ -^\xdeB\xd6\xb3\x04@\x93\xf6\x1f\xcd\xdeE\xb95\xf6\ -\xef!\xd1'!\xca\xab\x12\x03=k\x14\xeae\x02\xf8\ -\x98?\x11\xc7\xfeW3\xfa\xeb\xf7\xacn6\xf0\xc4\xaa\ -_\xb0I\xd0\x93\x04\x90\xcbf\xee\xc2\xb7isdX\ -\xe6\xce=\xeda\xff\x1e\x921\x0a\xe5\x9d{\x06\x1aw\ -\x0f%\xdd:\xf5\x1cz\x92\x00h\xb0\xfdgF\xa3-\ -x\xa2,\xa0\xa4[g\xff\x1e\x12\xe9x1\x10\xf2\xce\ -\x9et\x11\xf7*\x01<\xeaO\xc4-\xfc\x5c\xcb\xe8o\ -\xe5\xde\x90w>\xb6\xe6\x17m z\x8e\x00r\xd9\xcc\ -\x83\xf8\x82=\x8c\x0c\xcb\xf1\xf2?\x86\x95\xaf\x848\x02\ -\xb8\xed\x96\xfeF1\x90\xc8e3\xf7\xaf\xf9e\x1b\x84\ -\x9e#\x00\xe0w\xfc\x89k\xb6Eo\xd9\x93\x14\x01%\ -\xb5z\xf6\xefAIKHJ\xb4\x18\xd86\xde\xf4\xee\ -\x9e\x13\x03\xbdH\x00\x0f\xf8\x13\xf1\xda\x7f\xb4n\xd0*\ -Vi\x14zp\xdd/\xec2\xda\x1e\x22\xc6\xddE;\ -\x80\xe3\xa2\xed\xc71\xd6\xf4\xf9~\x09\xf7\xff\x14\x8e&\ -\x9f\x04\xd2\xee\xff\x09\xf7\xe7\xe5\xcb\xbe<\xd9}n\xbd\ -W\x87\x86dvOD\xef\xfb\x8b\xeb\xbcV\x91\x1aT\ -(\xabz\xe8\xb5\xdd7\xf714$\xb3\xb0`xY\ -J.\x9by\x1eX\x02\x0c\x9c`S\x9a\xfbw\xcd\xfd\ -yy5\xa0\xe2\xcb\xaf\x02e\xf7Z\xd9\xf7\xab\x02%\ -\xf7\xb7\x94/\xa8\xda\xba?\xca\x87\xd0\x16r\xa3k\xfc\ -.\xf0I\x9cm\xd6\x9e\xc5mSE\x14\xd9\xb3;z\ -\xf4K\x8a\x88\x92Z?\x83SR\x22\x92\x22b\xeaV\ -d\x1d\x0e\xffs\xd1\x9f\xf5\xd4\xba_\x1a\x83\x5c\xb6\xa5\ -\x95h\xb6\xfb\x7f\x15\xb8\x04|\x17\xf8\x0faQN\x9a\ -Z(\x97\xcd|\x05\x87\x22\x9f\x05v\xe1\x8cN\x81M\ -\xd6\xf9;w\xa4x\xfc\xd1\xe8M\x9f\xebQ\xfeV\xf3\ -\xac\xc7\x1f\x1da\xe7\x8eT\xe4\xf5\x0d\x82\xd7_i\x9c\ ->|\x16\xa8\xe6\xb2\x99|X\xc1:r\xd9\xccQ`\ -O\x17*\xb8.\xec\xb9\xa5\x9f\x1f~f<\xd6\xb7?\ -\xb6\xab\x0f9\xd9\x1e\x15\xc7\xa8Y\xcc\x9e*G^\xb7\ -m\xf8\xce\xde+\x1c;\xde\x14`j3\xe2X\xbe\xa0\ -\xde\xee%\xeaM\x98\xcbf\xde\x02\xeeX\xef\xd3%I\ -@Q\x04\x12\x8a\x88,\x0b\xc8\x92\x80\xac\x08(\xb2\x80\ -\x22\x8b\x88\x12(\xb2\x88$\x0b\xc8\x92S^\x96\x04$\ -I@\x14\x057\xed\xe4\x0b\xa2\x80$\xba\x7f\x0b\x02\x89\ -\x84\xc0\xd0\xa0\x1c\xeb\xf6\x05PR\x12\x99\x9b\xda\xbb\x93\ -[=]A\xaf\xc6\xc7\x89*.\x18,,\x1ah\x9a\ -\x8dm\xdb\x98\xa6\x8di\x81m9\x7f\x1b&\x98\xa6\x8d\ -UO\xfb\xf2\x0d\x1b\xdd\xb0\xb0L\xd0\x0d\x0b\xdd\xb01\ -t\xa7\x8ca\xd8h\xba\x85\xae;\xe5\xdb\x80\xb7\xf2\x05\ -\xf5.p\x09 \x97\xcd\xfc6\xf0\x0b\xfe\x12\xde\xfc\xfa\ -\xe1\x07\x86\x91c\xfc\xe3\x9b\x11#\xd7\xa7bW\xf6\xac\ -\x05\xb5%\x83\xf9s\xd5\xb6>\xb3\xd30L\x9b\xc3G\ -\x8a\xbcs\xbc\xc4|\xd1h\xbc\xfc;\xf9\x82\xfa\x8b\x82\ -\xab\xf0U\xf1)\x84;w\xa4\xf8\xecgzs\xbd\xa3\ -\x92\x96\xc8\xdc\xd8\x998\x0e\xea\x99\x0az\xa57\xa3\xc5\ -\xfd\xf5\xdf]\xe2\xec\xf9\x00\x01\x1b\xf9\x82\xaaH\x93\x13\ -\xe9\xaf\x01\x8fx\xb9#\xc32?\xf5\xaf\xaf\xebz\x05\ -\xdb\x01I\x11\x18\xbd!\x1d\xbb\xbe\x7f=H\x0e\xc8\xd4\ -\x16\x0d\xec\xf0\x09\xc1\xa6\xc6\x9d{\x06x\xfbX\x89\x9a\ -V\xaf\xbc89\x91\x1e\x13q\xc2\xa6\xd7q\xeb\xee~\ -\xec\xb6\x88\x99\xeeBRDF\xaeO\xc7.\xe7Z/\ -DI`\xe4\xfat\xacup\xb3\xc2\xb6C\xe3\x1e\xfd\ -\x84\x88/\x9e\x0e\xc0\x83\xf7\x0f\xd5o\xe8\x15\xa4\x06e\ -\xc6v\xa5\xdb\xa6\xf5\xc7AN\x8a\x8c\xed\xea[\x97\x93\ -\xa9\xdb\xf0\xfa\xf2\xa1\xe6\xdd\xd2\xdbd\x1a\x82!K\x02\ -\xd8\xb6\x8d \x08\xd86\xb1S\xad\x8d\x84 \x0a\xa4\x06\ -e\xd2#J[\x0c>\xab}\xf7\xf0\x8e\x14}U\x8b\ -\xca\xbcNu\xd1\xc0\xb66\xe7\x88\xf1:\xdf\xb6mB\ -\x98cR\x06,|\x06!]\xb7Q\x14\x10%\x10\x08\ -'\x02%\xed\xdb^%8\xe5<\xd3\x83\xe0\xe6\x01\x08\ -^!\x81\xd0\xf2\xa1\xcf\xc0\xcd\x0f{\x06 H\x02R\ -\xc2\x99Fn4\x94\x94\x88rm\x92\xa1k\x93\x98\x86\ -\x8d\xa9Y\xd8\xa6\xed\x98\xe1|\x0d\x8f\x9b\xaesU\x1b\ -l\xe7\x1f\xb7Lt^\xd33\xbcg\x87\x94oTP\ -\x97os\xa6\x9e\xba\xdeD\xa4\x96\x0c,\x02\xa3^\xce\ -\xb1wK\xdc\xbe\xa7\x1f\x19\x10EB9\x81 \xc0\xf0\ -u\xa9\xd8\x05\x13\x1f6H\xb2\x80$\xaf\xdd\xf3\xb8\x1e\ -X\x86M\xf1Bp\x8a\xea\x1f\xf9\x96\xe5\xd8\x13\x8e\xbd\ -\xdbd\xa8Z\x14\x81i\x7f\xce\xf1\x13e\xf4\x9a\x85a\ -87z\xd4\xe7\xd7\x09\xb4\xb2\xc9\xec\xe92Z\xb97\ -\xa7D[\x09a}\xd1\xd4\xf9\x86\x8d^\xb38~\xa2\ -\xc9\x9ayR\x9a\x9cH\x1f\x04~\xde\xcbY\x5c2\xd1\ -4\x8bk\xb7'\x5c\xae.\x80\xe8c\xc58\x1c\xc0\xb6\ -\xa0\xba` \x08\xacj\xc5\xedU\xb4\x0f\xa5Y\x8d\x85\ -\x8b\xb5\xfa\xb4\xd4?H-\xdb\xc62l\x0c\xddB\xd3\ -,\x0e\x1dY\xe0\xfd\xe9J\xe3#\x9e\xf1,\x81\xa7\x80\ -\x9b\xfcW\xee\xb9s\x80\xfb\xee\x1e \x91\x90\x90e\x11\ -Y\x12\x10|\x84\xe0\x17\x09\x89>\xe9\xaaH\xe8\x22<\ -\x96\x1f5\xeam\x0b\xd7\x84l\xa1i&\xaf\xbc\xbe\xc4\ -ko-5>\xe6T\xbe\xa0~\xc4S\xfe~\x84e\ -\x17\x22\x00\xaf\xbd\xb5\xc4\xde\xfdsT+&\x9af:\ -v\xea\xab\x22a\xc3\xd1\x0a\xcb\xd7\xdd\x8e\xafVL\xf6\ -\xbe0\x17\xd6\xf96\xf0\xa3\xe0\x1e\xa805]\x99\x99\ -\x9cH\xef\x07>\x8f\xcfA\xb4\xb0hr\xf6B\x8d\xcc\ -\x88B*\xe9\x8d|O\x83\xbf*\x12\xba\x8d\x15Y\xbe\ -\xe9\xb0|]\xb3\xb84\xa3S80\xc7\xc5\xcbM\xeb\ -Gl\xe0\xa9|A=\x02\xbe\x135\xa6\xa6+\xa7'\ -'\xd2\xff\x88\x13j\xb5\xde\xbb\xe5\x8a\xc5\xbb\xef\x97\x11\ -\x04\x81\xf11\x19\x965\x83\x00!x\xf4\xa0\x95M\xf4\ -\xaa\xb9b\xe4\xad\xabh\x1d\x96i3\x7f\xbeJ\xc5\xe7\ -\xd0ib\xf9\x86\x8d\xae[\xd44\x93\xd7\xde,\xb1\xff\ -\xe0<\xe5J\x93\xcd\xda\x02>\x95/\xa8\xfb\xbc\x8c\xa6\ -\x1er\xa3^\x1c\xc5\x17r\xd5\xc3\x0d\xd7'\xc9>1\ -J\x22)\xa2\xc8\x8e\xbbW\x14\x85P\xbd@\x94\x1dc\ -Ib\x0dk\xf2\xafb\x19Z\xd9\xa4x\xa1\x8ae,\ -\x0f\xf7P-\xdf\xb0\xd0j\x16\x85\x97\xe6\xf8\xe0\x5c-\ -\xecQ\xb3\xc0\x9e|A\x9d\xf1gF\x0e\xd1\x5c6\xf3\ -m\xe0\x87\x1b\xf3\xc7\xc7\x14>\xf6\xc8\x08\xe3\x19\x19E\ -\x11\x91\x15\x11Q\x12\x10\x1bD\x82\x87\x81\xf1\x04\xfdc\ -[\xf2\xb0\x8d\x8e\xa34\xab\xb1te\x99\x85G\xb2|\ -\xddbf\xd6\xe0\xe0\xe1y\xae\xcc\x86\xae_\xfcv\xbe\ -\xa0\xfeh\xd8\x85\xc8\xe195]\xf9\xc6\xe4D\xfa4\ -\x8e\xb3( \x12\xdey\xaf\x8c(\x0a\x8ce\xae\x8a\x84\ -N`\xb5,\xff\xf5\xb7J<\x7f`.\x8a\xe5\xfft\ -\xbe\xa0\xfeJ\xd4\xbbV\xec\x11W$\x1c\xc3g-\xf4\ -p\xe3\xce$O\x7f\xacu\x910\xb2#\xb5\xa6mZ\ -\x1f&\xe8\x15\x93\xf9\xf3\xad\xb3\xfc\xe7\x0f\xccq\xe6l\ -(\xcbW\x81\xdb\xf3\x05\xf5R\xdc\xfbZ\x1e\x92\xb9l\ -\xe6\x1f\x80O4\xe6osE\xc2XFFIH\x0e\ -\x11\xc4\x89\x84m\x09\xfa\xb7\xe6\xf9K\xebFI\xd5X\ -\x9aY\x81\xe5\x1b6\xbaf2\xab\x1a\x1c84\xcfL\ -8\xcb\xff\x87|A\xfdd+\xefly8NMW\ -\xfejr\x22}\x0e\xf81\x1aE\xc2\xfbeDQd\ -lT\xa2\x15\x91`T-\x12\xfd\xd2U\x91\xe0\xc22\ -m\x8a\xe7\xabT\xe6[`\xf95\x937\xde.\xf3\xfc\ -\x819J\xe5&\x96o\x03?\x9b/\xa8\xbf\xd4\xea\xbb\ -W\xdd\x03n\x5c\xbe\xb7\x81\xa65\xd97\xdd\x90\xe2\xa9\ -\xc7G\x1c\x91\xa08\xd6\xc3(\x91 \xb9\xb3\x84\x0f\xbb\ -H\xd0+&\xc5\xf3U\xcc8\x96o:\x9d\xaf\xd5,\ -^88\xcf\xe9\x0fB\xd7&\xce\x03w\xe4\x0b\xea\xf9\ -\xd5\xbc\x7f\xcdC0\x97\xcd\xec\x05~\xb01\x7f\xdb\x98\ -\xc2\xc7\x1f\x1bat\xa4\x05\x91 \xc0\xe0x\x92\xbe\xcc\ -\xfa\xb7p\xf5\x22\xca\xaa\xce\xe2\x95Z\xd0\x05\xec\xa2\x91\ -\xe5\xabs\x06/}?\x92\xe5\xef\xcb\x17\xd4g\xc2.\ -\xac\x845\x0f\xbf\xa9\xe9\xca_NN\xa4/\xe0\x84L\ -m\x12\x09\x92$\x92i\x14\x09b\xfd\xaf\xa0H\xa8}\ -\xb8D\x82e\xda\x14/\xd4(\xcf/w\xa6\xdfwo\ -\x9bA\x96\xff\xe6\xd12\x85\x97\x22Y\xfe\xb3\xf9\x82\xfa\ -\x8bk\xad\xcb\xba[\xdc\x8d\x93\xf7&\xbe\xc3\x1a<\xec\ -\xba1\xc5\x93\x93-\x8a\x04E`\xf8\xba\xad/\x12\xf4\ -\x8ac\xd81\xf5\xd6X\xfe\xfe\x97\xe79u&\x94\xe5\ -\x17\x81\xbb\xf2\x05\xf5\x83\xf5\xd4\xa7mC.\x97\xcd\xbc\ -\x00<\xd9\x98\xbfmL\xe1\xc9\xc9QF\x86\xa5\xd6D\ -\xc2\xb6$}1\x11?z\x19\xe59\x9d\xc5\x99\xd6X\ -\xfe|\xd1d\xff\xcbsQ,\xff\x85|AmKx\ -\xba\xb6\x0d\xb7\xa9\xe9\xca\x9fON\xa4gp<\x8bM\ -\xbe\x04I\x14\x18\x1buC\xb5\xda1\x22\xa1\xe4\x88\x04\ -'\xaa\xf7\xd6\x10\x09\xb6eS<_\xa3<\x17\xcf\xf2\ -\x0dwn\xff\xe6\xd1\x12\x85\x03s,\x95\x9a\xbc\xab6\ -\xf0s\xf9\x82\xfa\x1f\xdbU\xb7\xb6\xb7\xb0\x1b\xbe\xf5\x0d\ -\xa0)f\xeb\xc4M)\x9ex\xcc\x11\x09\x09ED\x96\ -\xc4\xc85\x06JZbtg\xaa\xe7\xf5\x02\xdb\xb2\x99\ -;[\x0d\xac\xd7k\xf6\xdd[h.\xcb\x7f\xf1\xe5y\ -\xa6\xc3Y\xfe\x02pw\xbe\xa0\x9ejg\xfd:\xd6\xba\ -\xb9l\xe6EB\xc2\xa7]3\xae\xf0\xd4\xe3#\x0c\x0f\ -+$\x12\x0e\x11xk\xf9\xc3\x16\x9fvj\x97O\xb7\ -\x10\xb6\x9b\xc8\xb6}#\xdftV\xec\x14\x8b:/\x1c\ -\x9c\xe7\xf2\x95P\x96\xffR\xbe\xa0~\xbc\x13\xf5\xeb\x98\ -\xc655]\xf9\xb3\xc9\x89\xf4\x1cN\x8c\x81z\xd7\x96\ -\xca\x16\xef\xbe_!\xa1\x08\x8c\x8f)\x8eR\x18\xc1\x05\ -,\xc3F\x94\xd6\x16\xe5k3\xa0<\xa7S)\x06;\ -\xd4\xf6\xc9\x7f\xd3U\xf6\xde:\xba\xc4\xbe\x97\xe6Y\x5c\ -\x0ae\xf9\xbf\x90/\xa8_\xeaT\x1d;\xce_s\xd9\ -\xcc\x04\xf0:Nt\x8f\x00n\xb99\xcd3O\x8e\xa1\ -(\xd1\x5c@\x10\x05\xc6'\xfa:\xba\xe3\xa7\x13\xb0L\ -\x9b+'\xcbM\xfb\x05<\x02\xb0\xdc\xce\xff\xde\x0b\xb3\ -\xbcw\xb2i\xad\x1e8QF\xee\xce\x17\xd4\x93\x9d\xac\ -g\xc7wT\xb8\x1f0\x02L5^;~\xa2\xc27\ -\xbf=\x83\x1d\x5c\x8d\x16\x80m\xd9\xd4\x16\x9bv\xb6n\ -z\xac\xb4Y\xc4\xc6\xe6\x9b\xdf\x9e\x89\xea\xfc\x83\xc0H\ -\xa7;\x1f\xba\x1c\xf5#\x97\xcd\xfcg\x9c\xa3W\x03\xef\ -\xbd\xfe\xba$?\xf9\x99\xed\x08\x08\xa1;\x91\x12}\x12\ -\xa37\xf4\x96.0\xf7A%t\x8d\xa4'\xff\xff\xcf\ -\xdf]\xe2\xdc\x85&/\x9e\x0d\xfcR\xbe\xa0\xfeV\xd3\ -\x8d\x1dBW\xf7T\xb9\x1fv+N\xc0\xa3:\xce]\ -\xa8\xf1\xf2\xa1b\xf8M\x80^\xed\xbd\xed\xb8qu~\ -\xf9P1\xac\xf3K\xc0\x0ft\xb3\xf3a\x03\xc2\xc4\xe5\ -\x0b\xea{8V\xc3\xc3\xfe\xfcwb\xc2\xab\xd8\xd6\xf2\ -\x16\xa8^\x80m\x13\xcb\xfeC\xbe\xf5\x100\xdc\x0d\x96\ -\xdf\x88\x0d\xd9\xe2\xeaF\xabz$\x97\xcdTp#\x90\ --,\x1aX\x96\x8d\x14\xa1\xecY\x96\xdd3\x8a`\x5c\ -\xe7[\x96\xcdBP\xa7\xa9\xe6\x0b\xea\xa3Q\xe5;\x8d\ -\x0d\x0b\x14\xe9\x9e\xf6Y\x0f\xaf\xd5\x97\x96\x22;_\x10\ -\x85\x9e\xe9|p\xe2\x08D\x19\xb0$I\xa0/8\xad\ -M\xb9m\xb1!\xd8\xc8H\xa1_\xf1'\xe2\x0e|\x90\ -\x13\xbd\x17\xd04\xae\xce!\xdf\xfa\x1b\x1d\xadL\x0c6\ -\xb2e[>\xedSI\xf7\x1e\x01\xc4\xd59\xe4[7\ -\xec\x14\xd2\x0diY7\x9cl}\x91\xa9$\x09\xb1\xa7\ -}\xb6;\xe2W7\x10W\xe7\xfb\xef\x19j\x9c\xeef\ -\xdc`]]\xc7F\x0d\xad_\xf5'\xe2N\xfb\x14D\ -\xa1'7\x97$\xd2\xf1\x0b\x5cBb\x1d\xfe\xd7\x8eV\ -(\x02\x1bE\x00_\xf0'B\x82\x17\xd5\x91\x1c\x906\ -Y\x90\xda\x16!@\xb2?\x9apom\x8es\xfc\xb3\ -\x1d\xadO\x046\x8a\x00\xae\xf7'\x1e{(:\xe6o\ -/\xb2\x7f\x0fq\xd1\xca\x1f\x7f\xa4\xe9\x9bwv\xb42\ -\x11\xe8:\x01\xe4\xb2\x99\xcf\xe2\x1b\xd3q\xe7\xfd\xac4\ -\x8a6;\x92\xfd\xf1\xdc\xab\xe1\xdb\x05\xb7m\xba\x8a\x8d\ -\xe0\x00\x01Yw\xf3G\xa2m\xfc\x89\xbe\xde^(\xba\ -\x92\xfe\x12\xf2\xed\xbf\x1aV\xae\x93\xd8\x08\x02\x08D#\ -\x8f;\xf0\xa9\x97\xd9\xbf\x87\xb8o\x08\xf9\xf6\xdb\xc3\xca\ -u\x12]%\x00\xf7P\xa5z\x8b\x8c\x8e(a\xe7\xee\ -\xd4\xb1\xd5\x09`\xdbx\xa2\xf1\x18Z9\x97\xcd\xdc\xd3\ -\xf1J\xf9\xd0m\x0e\xf0e\x7fb|,Z\xfe\xcb\xc9\ -\xcd\x11\x0bp\xbd\x90\x14!6\x82\xe9X\xf3\xa6\x98\xa6\ -C\x1d:\x89n\x0f\xb1\xae\x9c\xf7\x17\x07C\xb3\xd0\xcb\ -&Z\xc5\xc4v\xdd\xf5\x82\xe4\xcc\xdb\x95>\xa9#f\ -\xe7\xe4\x80\x8cQ\x0b?\xea\xe7\xb6[\xfay\xffd \ -|\xdbd\xdb+\x10\x83\xaeq\x00\xd7\xe1Q\xd7z\xfa\ -\xd2\x12\xbbo\x8e\x9b\xff\xb7\x97\x00\x8c\x9a\xc5\xdc\xd9\x0a\ -\xb3\xd3e\x16.\xd5\xa8.\x18\xd4J\xce\xaf\xba`\xb0\ -p\xa9\xc6\xect\x99\xb9\xb3\x15\x8cZ{\xd7\x1f\xc4\x11\ -\xf3\xad?\xd0\xd7\xe8\x1cJw\xd39\xd4M\x11\x10p\ -x\xc49\x7fVb\x9b\xab\x81m\xc1\xe2\xa5\x1a\xb3\xa7\ -\xcah\xcd\xeb\xec\x9b\xa0\x95LfO\x95Y\xbcTk\ -[X\xf8\x95\xc4YH[|9\xac\x5c'\xd0M\x02\ -\xf81\x7f\x22\xce\xf9\xd3\xae\xd1\xafW-\xd4\xd3\xe5\xa6\ -=xq?\x0f\xe5y\x1d\xf5t\xb9m\xdc \xee\x9b\ -B\xda\xe2\xc7\xc3\xcau\x02]!\x00\xd7\xd1Qw\xfe\ -\x08\x02\x1dw\xfe\x94T\x0d\xf5L\x19\xc3= \xc1\xdf\ -\xc16N\xbcC\xcb\xfd\xd9\xb6]_\x98\xea/gh\ -\x16\xb3\xa7\xcb\x94\xdapT\xdffu\x0eu\x8b\x03\x04\ -\x8c?q\x87>\xad\xd7\xf9c\x1a6s\x1fT\x9cH\ -\x1b\x0d{\xf0l\xec\xc0\x1e@ _&$@g\xbb\xd0i\ -\x1d \xe0\xd8X\xab\xf5O+;\xe6\xd9\xd0\xce\xaf\xcf\ -\xeb\x1dv_\xad\x9a\xbc\xf1v\x89\xbf\xfd\xfb\xcbQ\x9d\ -\xff<0\xe4u>\x80\xfb\xf7\x10\xf0Bc\xe1\x0b\x97\ -4\xfe\xf6\xef/\xf3\xc6\xdb%\xaaUw\xbah4O\ -\x17=T\x17\x0d\xc7\xec\x1csx\xc6*\xad\x82\x1du\ -\x0eu2B\xc86\xe0\xb2\x97\xeeKK|\xe9\x0b\xd1\ -\xcb\xde\xc2\x8e{\xb7mX\xbaR\xa3\xac6\xc7\xd6\x81\ -\xe5\xc0J\xa6/\xa4\xdaK\xdf/F\x85X1\x81\xcf\ -\xe5\x0b\xea_\xafP\xef\xcf\x02\xdf D<~\xe4\xc6\ -\x14O<:L\x22)\x91P\x1c\xfb~\x5cX\xdc\xbe\ -\x8c\xc2\xc0x\xb2i\xc7\xb3g\xa2\x8e\xc2\xd7\xff\xf8,\ -\xe5`T\x91k\x1a\xc3\xbc\xb7\x0b\x9d\xe4\x00\x01\x87F\ -\xac\xf3Gnv\xfe\x18\x9ak\xc7\x0f\xe9\xfce\xa3\x8e\ -\xd3\xf1\xd5\x9a\xc9\xf4\xe9\x0a\xcf\xedU\xa3:\xff$\xce\ -~\xfb\xd8\xce\x07p\xcb\x8c\xb8\xf7\x040}\xa6\xcas\ -{UN\x9f\xa9P\xad9\xb3\x04C\xb7#\xb9AY\ -u\xfd\x09Z\x90\xdd+\xa9U;\x87~}\xa5z\xaf\ -\x15\x1dS\x02''\xd2\x7f\x82\xcf\xfd\xfb\xc0\xbdC\xec\ -\xb86\x19Z6=\xa4\x04\xd8byN\xa7\xe8\x8b\x98\ -\xdd8\xeam_\xc4\xecZ\xd5\xe2\x9f^]\xe4\xd0\x91\ -\x85\xb0p\xe96\xf0\xbf\xf2\x05\xf5\xd3S\xd3\x95\x96\x0d\ -\xfaS\xd3\x15mj\xba\xf2\xd5\xc9\x89\xb4\x02|\x8c\x86\ -\xa8g'NU\xd1u\x9bk\xc6\x95\xa6\xc3.\x9b\x14\ -D\xd3\xa6Z\xd4\x11\xa4\xa0\x82h\xeav\xe4\x16r]\ -\xb7\x1bc\x03\xde45]\xe9\x88\x87\xb0#\x22\xc0u\ -d\xd4\x05\xb6 \xc0\x7f\xf9\xf9\x9b\x22\xcb\x8f\xeeL\x93\ -\xe8\x97\xb0\x0c\x9b\x85\x8bUj\xa5\x88\xe9\x9dO\xd1\xd3\ -4\x8b\x85%\x93\x17_\x9e\xe3\xd2L\xa8V]\x04\xee\ -[\xef\x96k7\xc4\xcd+\x84\x04\xc2\xdc\xbeM\xe1\xe3\ -\x93\xa3\x0c\x0dHN\xc0+Yt7\x866\x87\xbf\x03\ -\xc7\xc85t\xads\xba\x9aV2\x99;\x1b\x1a\x1d\x04\ -\x80\xdf\xfc\xbd\xd3\x8d\xe77\xcb\xee\xae\xea\xb6\xa2#\x1c\ -`r\x22\xfdk\xc0S^zdX\xe6\xbe\xbb\xc3\xbd\ -\x7f\x82(0\xb8=\x89V2\x98?[\x0dh\xc7\x8d\ -\x114\x1d\xc3\x8eM\xadf\xf2\xfa\xdb\xce\xb98\x0b\xcd\ -\x81\x95\x00\xbe\x95/\xa8\xf7LMW\xe6\xd6\xfb-S\ -\xd3\x95\xb9\xa9\xe9\xca\xff\x9e\x9cH\xdf\x0b\xdc\xe6\xbfV\ -*/\x1f\xc686\xea\x1e\x9e\x112]\xf4\x88\xc0\xd4\ -m\xaa\x0b\x06rB$\xd1/;\xd3\xc1\x08#\xd2\xd1\ -wKT\x833\x85\xda\xd4t\xe5\xc0z\xbf\xa7\x11\x9d\ -\xd2\x01Zv\xfe$\xfa$\x16/\xd7\x98?W\xc52\ -\x9b-n\x9eE\xcf\xd0m\xb4\x9aE\xa5b\xb0\xef\xc5\ -9\x8e\xbc\xb6\x88\xde\xec\x9d\xd3\x81\x1f\xca\x17\xd4O\xb7\ -\xf1[\x00p\x9f\xf9#\xee;\x96_\xa8\xdb\x1cym\ -\x91}/\xceQ\xa9\x18h5G/\x88\xb2 Z\xa6\ -\xcd\xfc\xb9*\x8b\x97k\xb1\xa7\xab\x85\xb4\xd9\x17\xdb\xf7\ -5\xcb\xe8\x14\x01\xec\xf0'\xe2v\xfe\xd4\x96\x0c*a\ -A\x93}\x8a\x9e7\xbd{\xefD\x99\xe7\xf6\xaa\x9c\x09\ -?\x14\xe9-` _P\xbf\xd7\x86\xfa\x87\x22_P\ -\xbf\x83\x13\xed\xec\xed\xc6kg\xce\xd5xn\xaf\xca{\ -'\xca\xcb\xd3\xc5\x18\x05\xb12\xafS[\x8a\xf6,\x86\ -\xb4\xd9\xf5a\xe5\xd6\x8bN\x19\x82\x1a\x9ekc\xdb\xe1\ -\x01\xa0\xea%\xa2\x14=7\x82\xe6\xe1W\x168v<\ -t\xead\x03\xb9|A\xfdJ\xd8\xc5v#_P5\ -\xe0\xce\x5c6\xf3\xcb83\x9d\xfaW]Qu^x\ -y\x9e\x8b3\x1a\x0f\xdf7D\x22\xe9\x19\x8f\x1c\xbd@\ -t\x9dJ\xd0\x1c\x0e\xcf\x0f\xa7L\x13w\xebH_\xb5\ -\x9d\x03\xb8\xd1\xc3\xeb\x18\x1c\x90\x02\xc1\x11\xc3\x10X\xb0\ -\xe1\xce\xeb=\x9f\xfd\xcc\x8c\xc6s\xfbf\xa3:\xff\x0a\ -\xb0\xb3[\x9d\xefG\xbe\xa0\xfe\x06\x8e\x9b\xfbJ\xe3\xb5\ -c\xc7\xcb<\xb7o\x96\x99\x19\xad\xbe\xd6\xc0t\x17\x9d\ -4-8i\x80\xbf\xad\x06\x07\x82\x22\xa2\xb1m\xdb\x81\ -NPU\x93\xbd\xd74m\x10\x82#\xa0\x11\xf5Q\xef\ -\xfa\xeck\x9a\xc5\xebo,\xf1j\xf3\xb1\xa7\x1e\xfe\x22\ -_P?\xdf\xbej\xaf\x1e\xee\xe9\x1c\xdbr\xd9\xcc_\ -\x00?\xe5\xbf6sE\xe7\x9b\xdf\xb9\xc2=w\x0ep\ -\xf7]\x03$\x13\xb6\xb3\xd6\x80fn\xd0\x08O\xef\x09\ -A\xdb\xc3\xa8\xb7\x9d\x034N\xbb\x16\x97Lt\xcdS\ -\x8c\xac\xc0\x1af\xfd+SC\xd0)%\xf0E\xa0~\x86\ -\xcd\x89\xe9\x0aw\xdc\xda\x8f\x9e\x10P$\x01Ar\xcc\ -$\xb6\xed\x1av,\x1bC\xb3\xf9\xfe\x91\x22'N\x85\ -\x9ar-\xe0\xdf\xe7\x0b\xea\x1fu\xa8\xbem\x81k\xa8\ -y8\x97\xcd|\x11\xf8:\xbe\x01V\x5c09x\xb8\ -\xc8\xc5\xcb\x1a\x8f\xdc?\x84\x9c\x10\x90\xdd\xe8g\x82\xe0\ -\xa8|\xb6i\xa3\x9bN[\x9c\x98nj\x87\xfd\x9d\xa8\ -s\xa7,\x81i\x9c\xc8\x97\xf5\xe7\x8f\x8d\xca\xec\xd9\xdd\ -\xc7\x0d;SN\x84p\xc0\xb2\x9d\xb8y'N\x969\ -y\xa6\xca\xec\x5c\xe8\xb4\xe8\x1c\xf0\xd1|AU;Q\ -\xd7N!\x97\xcddp\x8e\xd2\xd9\xd1xm<\xa3\xf0\ -\x91\x1b\x92\xdc<\xd1\x87(\x0a\x88.\x01X\x96\xcd\x07\ -\x1fT9\xf6~\xb9\xb1-l \xe5\xce@\xda\x8aN\ -z\x03\xff\x0a\xf8\x5cc\xfexFf\xe7uI\xc6\xc7\ -\x14.^\xd68wAc\xae\x189\x1f\xfeZ\xbe\xa0\ -\xfe\xa7N\xd5\xb1\x1b\xc8e3_\x05BO\xf8\x18\x1d\ -\x96\xb9\xfe\xba\x04\xd7^\x93\xe0\xca\xac\xce\xd9\x0b5\xae\ -\xa8\xa1m\xf1\x8d|A\xfd\xb7\x9d\xa8_G\xb7\xdf\xe6\ -\xb2\x99\xfd\xc0Z\x0e:(\x01\x8f\xe6\x0b\xea\x9b\xed\xad\ -\xd1\xc6\xc0]\x19\xd5\xb4\xe0\xa4E\xbc\x98/\xa8O\xb6\ -\xb7F\xcb\xe8\xc6y\x01\xfbY\x1d\x11\xac\xf9\x0c\xbc\xcd\ -\x0c\xd7A\xf6=`5\x87=\xed\xcf\x17\xd4\xa7V.\ -\xb6vte\x03~.\x9by\x1c\xf8\x1b`{L\xb1\ -\xe3\xc0\xa7\xdc`\xd2[\x16\xb9lf7\xf0\x1c\xb0;\ -\xa6\xd8%\xe03\xdd\x98\xedt\xfb\xbc\x80\xdd\xc0\xe7q\ -\xce\x12\xda\x01\x9c\xc09\x1c\xe1\x8fW{\xe4i\xaf\xc3\ -=\x82\xf7\x0b\xc0\xe3\xc0\xcd\xc0y\xe0%\xe0\xcf\xb7\xfa\ - \xb8\x8a\xab\xb8\x8a\xcd\x82\xff\x0fR\xb9\xc7\x85|\xfd\ -\xc5\xb0\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x12\xa0\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\ -\x00\x00\x05>iTXtXML:com.\ -adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \ - \x0a \ - \x0a \x0a \ -\x0a \x0a \x0a \x0a\x0a'I4\x15\x00\x00\x01\x81iC\ -CPsRGB IEC61966-\ -2.1\x00\x00(\x91u\x91\xcbKBA\x14\x87\xbf\ -\xb4\xb2'\x06E\xb4h!Q\xad4\xac@j\xd3B\ -\xe9\x05\xd5B\x0d\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\ -\x06m\x83\x82\xa8M\xafE\xfd\x05\xb5\x0dZ\x07AQ\ -\x04\xd1\xb6\xd6EmJn\xe7j\xa0D\xce0\xe7|\ -\xf3\x9bs\x0e3g\xc0\x12L)i\xbd\xd6\x0d\xe9L\ -V\xf3Oz\x1d\x0b\xa1E\x87\xed\x85F:\xa9\x17k\ -\x09+\xba:\x1b\x98\x08Ru|\xdeSc\xfa[\x97\ -Y\xabz\xdc\xbf\xa39\x1a\xd3\x15\xa8i\x10\x1eST\ --+<%<\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\ -\xf0\x99\xb0S\x93\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\ -\xfcm\xb2\x16\xf4\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\ -\xa4\x96\x16\x96\x97\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89\ -e\xe6\x03\xe2{du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\ -\x87\x87AF\xc5zp1\xc4\x80\xec\xa8\x92\xef.\xe6\ -\xcf\xb1*\xb9\x8aX\x95<\x1a+$H\x92\xc5)j\ -N\xaa\xc7\xc4\xc7E\x8f\xc9L\x917\xfb\xff\xb7\xafz\ -|x\xa8T\xbd\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(\ -l\x19\xc6\xd7\x91a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea\ -!\x8c|\x88\xbeU\xd6z\x0f\xc0\xbe\x01\xe7We-\ -\xb2\x0b\x17\x9b\xd0\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\ -\xf0v\x0a\xad!h\xbf\x81\xa6\xa5R\xcf~\xcf9y\ -\x80\xe0\xba|\xd55\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00\ -:3\xe84\xb1\xb9\x01\x0f\x96*\xb7{\x5c\xb0\xad\xf8mgZ\x9d\xf6qX?\x1a\ -\xe8I\xfc/\x82z\x18p\xc1\xb6X\x91\x1e\x0f\x97j\ -\x94\x0c\x83\x92\x10\x14\x84\xc0\x14\x02\xa1\xc5\x9f\x19\xc2\x02\ -\xdaS\xfe$Q[\xf9Y\xff9{5-\xe5w\xf0\ -\xb5\x06\xc6\x9c\x1c\xda0L\x0e\x16J\xdcY(S\x10\ -\x82\x02\x22\x98\x0f\xa0M0mB\xf1\xc32\xdfU\x8a\ -o\xdd\x01W\x5c\x9b\xb6\xf4\xe2\x97\x8f\x9e\x1c\x1a\xb2\xd1\ -\xf4\xf0\x8a\x10\x1c0\x8b\xdcY(\x0e\xbf\x104\xd3'\ -\xac\xe9\x7f\xeb:\x5c\xf5\x1c\xac\xf4\xa6\xe0\xcd\xa7\x87\x87\ -\xe8\x05\x22n9:\xc0\xe3\xd1\x94\x1f\x92\xda\x1d\x1c\xac\ -$q\x10\x7fy\x11M\xb69\x8d?\x1d\xe8\ -E\xa2nU\xb6\xbfHT\x9c\xa0\xc5\xf0%\xe0Y\xfc\ -\xd9DU\xa0\xb8\x95ghv\x04\x058@\x0f\xff+\ -\xee\x14\xf0\xf2\xb8\xcb\xc4i4\x1a\x8dF\xa3\xc9'\xe3\ -|\x05\xe8N\xbf\x0c\xd3\xea\xb47\xd4X\x0f\x0b\xcf9\ -\xda\x009G\x1b \xe7\x146\xbfd=\xbfk\x1cL\ -\xad8T\xcc\x02\xb7\x95\x12K\x91\xed8\xd1\x11J\x02\ -\x85T\xc9\xd1\xcc\xdb!\xda\xdf)\x00C\xf8\xe3 \xc2\ -\xe3i\xb4\x82-\xdb}\xac\x94\xf5\x80\x15\xf0\xc7\xf6\x95\ --=k\xcb\x06\x98%\x86\xc3\xa0\x94\x0a\xfa\xc3\xd7\xba\ -F\xd7\x86Hm\xdf\x00\xa1\xd0\x86\x10\x08%0E0\ -\x14f\xc4X\xc9,\x91Y\x03H\xfc\x14\xef\xa9\xa0/\ -<\x18\x02\xe5\xaa0l\xf44\xb7\xad\xe0\x8b\xef\x8f\x82\ -2\x83\x011Ea\x0cWK/\x08\x81Dd\xb6,\ -\xcd\xa4\x01\xc2aP\xc3\x110\xd2\xa3/%\xfd` \ -\xab\x8d\xc4S\xfe\xdc\x86\xed\xceb0\x00\x11\x8c\x82*\ -aP2\x0c\xe6\x0c\xd3\x1f&g\xf8#\x22L\x91\xdd\ -\xe1q\x993@\x98\xb5{\xc1\x0a\x99\x03)Y\x95.\ -\x97\x06\xab|=\xb0\xf8\x9f\xe7\xd0\x93\x92\xc1\x84'\xb0\ -\x94\x85A\xd50\xd8g\x16\xf9q\xb9\xc2O\xca5j\ -\x0a\x94aP4\x8c\xe1\x0a\x5cY3A6\x0d\x10d\ -\xfb\x8e\x92\xfc\xb3w\x83O\xac\x15n\xa4o\x9201\ -\x06J2\xf0$\xcb\x9e\xcbe\xdb\xe2\xef\xbd\x15\x1e\xa9\ -,\xf0\xd3\xda\x1eL%0P\x90\xc1es3g\x00\ -\xf0\xcbvW)\xde\xeb.\xf3\xd1\xea\xf5\xa9\xc4\xe1\x86\ -\xe7\xf2n\xb7\x8d\xa5$\xcd\xfa\xbe\xb5\x8aa\xc6\xc8T\ -\xdde\xad\xec\x87\xf7\xba\xed\xa9\x89\x1f\xe5\xa3\xd5\xeb\xbc\ -\xd7m\xe3\xa9\xb5\xe1\xd9Y\x22s9\x80R\xf0q\xef\ -\x06\xefw\x97\x13\xa7\x80W\x80\xb7\x80OZ\x9d\xf6w\ -\x93|o\xb3\xde\xb8\x03x\x04x\x0ax\x81Hq\xff\ -~w\x99\xbdf\x91\x9f\xd7\xf6\x92\xb5\xe5\x14\xb3g\x00\ -HK\xf9\xdf\x01\xbfiu\xda\xef\xee\xd4{\x03C\xbd\ -\x03\xbc\xd3\xac7\xfe\x0c\xfc\x09\xb8#<\xff\xd1\xeau\ -~V\xdb\x9b\xb9B SE\x00\xc0\xd7\x83\x1e\xd7\xd6\ -OvP\xec\xb0\xf8qZ\x9d\xf6\xdf\x80\xdf\x12it\ -\xbc\xe6\xda|=\xe8\xedV\x14&F\xe6\x0c\xf0y?\ -1\xd4\xed\x95\xdd\x14?$0\xc1\xab\xd1\xb0\x94\xb8\xcd\ -<\x993\xc0\x95\xe4VhoM#\x1ei\xefN\x89\ -\xdb\xcc\x93)\x03\x08`9\xf9\xbd\xff\xc9\x14\xa2\x12\xf2\ -q\xf4`\xd9su\x1d@\x93-2g\x80\xdb\x92\x9b\ -V<2\x8dx\x04<\x1a=H\x89\xdb\xcc\x939\x03\ -,&\xb7?{j\x1a\xf1H{\xf7\xe2\x14\xc6Cl\ -\x97\xcc\x19\xe0P%1_\xf5\x85f\xbd\xf1\xcb\xdd\x8e\ -G\xb3\xdex\x02x>\x1av(\x83\x1bmf\xce\x00\ -\xf7\x94\xab\x1c(\xac\xdb\x18I\x00\xaf\x05\x82\xec\x0a\xc1\ -\xbb^#\xd2\x1ax\xa0P\xe2\x9e\xf2\xe8\x1d\xd7f\x95\ -\xcc\xb5\x04\x02\x1c\x9do\xf0\xe6\xf5\xffF\x83\xee\x00\xfe\ -\xda\xac7^\xc5\xff4\xfbx\x87\x9a\x82\x1f\xc5\xcf\xf6\ -\x9f'\xd6\xf3{t\xbe1\xc9\xd7\xed\x1a\x994\xc0c\ -\xb5=\xacH\x97\xd6\xca\xb5h\xb0\xc0o\xa3\x7f\x01\xa0\ -Y\xdf=AN,\xec\xe7\xb1Zb\x93\xe6L\x90\xb9\ -\x22 \xa4Y\xdf\xc7\x89\x85\xfd\xd3\x8e\x06'\x16\xf6\xf3\ -D}\xdf\xb4\xa3q\xd3d2\x07\x08y\xa2\xbe\x8f\xba\ -Q\xe0l\xb7\xcd\xd5\xe4bH;\xca\x81B\x89\xa3\xf3\ -\x8d\xcc\xa6\xfc\x90L\x1b\x00\xfc\xe2\xe0\xb1\xda\x1e.\x0f\ -z\x5c\xecwY\xb2\xfb,{\x0e\x9d\x09\x8f\x10\xaa\x9b\ -\x05n3\x8b,\x96\xe6847\x9f\xc9\x0a_\x1a\x99\ -7@\xc8=\xe5\xea-#\xcan\x92\xd9:\x80f2\ -h\x03\xe4\x1cm\x80\x9c\xa3\x0d\x90s\xb4\x01r\x8e6\ -@\xce\xd1\x06\xc89\xda\x009G\x1b \xe7h\x03\xe4\ -\x1cm\x80\x9c\xa3\x0d\x90s\xb4\x01r\x8e6@\xce\xd1\ -\x06\xc89\xda\x009G\x1b \xe7h\x03\xe4\x1cm\x80\ -\x9c\xa3\x0d\x90s\xb4\x01r\x8e6\xc0-\xc5\xd6\x17\xa9\ -\xdb\xb2\x01F-\xc0\x9c\xb5\xf5\xf1\xb2\xcc\xa8\xff\xff\xcd\ -h0\x8e\x01\xd6=7}_b\x90\xa3Nh&\xce\ -\xa8\xffuJ\xf0\xa6\xa2\x8cc\x00w\xfdA\xfa*\xfc\ -\x8e\x94:\x17\xd8\x05\x14\xfe\xff:\x1e\x06\x0a7\xb96\ -\xba\xb3\xd9\xf3\xc61\xc0j\xf4\xa0/%RE_\x1c\ -\xfeVX\xde\xa6\xef\xd3l\x13\xcbs\xd6%\xbf\xf0\x97\ -T\xd0\x97\x89$\xb8\xe9\xc2\x85\xe3\x18`)zp~\ -\xd0\xc5\xc3_\x8b?\x1a\x01\x80\x15\xc7\xc6\x9d\xf02\xed\ -\x9a5\x5c%Yq\xd6&\xc1\x86\xff{\xa5\x14\x1e\x8a\ -\xf3\x83\xc4:\x85K\xf1\x808\xe3\x18\xe0\xf5\xe8\xc1%\ -\xbb\x8f#\xa5o\x02\xd6\x9b@*\xc5\xb5\x81E\xcfs\ -tq0A\x14\xd0\xf3\x1c\xae\x0d\xaca\xf9?\x14\x1f\ -_|GJ.\xd9\x89u\x0aOm\xf6\xecq6\x8e\ -\x9c\x07V\xa2\xd7\x1e\xad\xd4\xb9\xbf\x5c\xa5(\x0cL\x04\ -\x22e\xb3\x04\x81\xa0h\x18\xfe>;\x9b\xbdD\x93\x8a\ -\xc2OT~\xfd*\x99\xed\x87)\xdfQ\x92/\x06=\ -\xceZ\x9d\xf8\xed\x0b\x9b\xed\x22\xbe\xe9\xec\xe0V\xa7\xdd\ -m\xd6\x1b\x17\x81\x07\xc3\xb0\xcbv\x9f\x1f\x15\xcb(\x81\ -\xbf[\x86Z\xbf\x9bV\xf8~[z\x9b\xff\x95\x9a\xb1\ -P\xb1#\xa9\x18\xa6\xfc\xbe\xf2\xb8\x9cL\xfd\x17\xc7\xd9\ -B\xde\x1c\xe7\xe5w\x97+g\x81\x17\xc3\xe3\xae\x92t\ -\xa5\xc7\xedf1\x99\xbaE\x98\x13\xe8t?Y\x82\x9d\ -\xd0B\xe1\x83\x1dS,)\xf9\xb0\xdfa)\xb9@\xc6\ -\x89ol\xeb\xfb\xcd\x9e:\xb6J\xcdz\xe34p,\ -\x1a\xf6`\xa9\xc2\x03\xa5\xaa\xbf\x81\x92\xf0\xb3{3\xd8\ -aK\x90\xdd\x8d\x94f\x89p\x93\x0c\xbf8\xf0\xc5\x97\ -Ja+I_J>\xb7{\x5c\xb0\xad\xf8mgZ\ -\x9d\xf6\xf1q\x9e\xbf\x95\x05\x22\x9e\xc4\xafU\xd6\xc3\x80\ -\x0b\xb6\xc5\x8a\xf4x\xb8T\xa3d\x18\x94\x82m\xd5L\ -!\x86\xfb\xedi\xb6O\xd8\xf22\xdc%M)l)\ -9g\xaf\xa6\xa5\xfc\x0e\xbeVc\xb1%\x8d\x9a\xf5\xc6\ -a\xe0\x1c\xb1\xa2\xa3a\x98\xfc\xa0P\xe2\x87\x852\x05\ -!( \x82\xca\x9f6\xc1v\x09\xc5\x97J\xe1\x06{\ -%\xfd\xdb\x1d\xf0\x1f\xd7\xa6\x9d\xaccy\xc0\x91V\xa7\ -}~\xdc\xe7oY\x9ff\xbd\xf14\xf0\x06)\xf5\x87\ -\x8a\x10\x1c0\x8b,\x16\x8a\x14\x85\x81\x01\x18\xda\x02\xdb\ -B\xe2\xef}\xe8(\xc9\x92\xebp\xd5s\xb0\xd2\x9b\x82\ -=\xe0\x99V\xa7\xfd\xe6V\x9e\x7fS\xea\x049\xc1\x07\ -@\xf6\xd6F\xbd5\xe9\x00\x8fo%\xe5\x87\xdcTw\ -p\xab\xd3\xfe\x0c8\x08\x9c\xb9\x99\xfb5\x13\xe54\xb0\ -x3\xe2\xc3\x04\xbe\xd5\x82\xdc\xe0$ph\x12\xcf\xd3\ -\x8c\x85\x02.\x02\xcf\x05\x89\xf1\xa6\x99\x98`A\x8b\xe1\ -K\xc0\xb3\xc0\x22P\x05\x8a\x93|GNQ\xf8\xbdz\ -=\xfc\xaf\xb0S\xc0\xcb\xe34\xf2h4\x1a\x8dF\xa3\ -\xd1h4\x1a\x8dF\xa3\xd1D\xf9?\x88\x82\xa9@\x0f\ -\x8d\xc9Y\x00\x00\x00\x00IEND\xaeB`\x82\ -\x00\x00\x19\xd7\ -\x89\ -PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ -\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\ -\x00\x00\x05\xc9iTXtXML:com.\ -adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \ - \x0a \ - \x0a \x0a \x0a \ - \x0a <\ -/xmpMM:History>\x0a\ - \x0a \ -\x0a\x0a.\x8f\x5ci\x00\x00\x01\x81iCCPsRG\ -B IEC61966-2.1\x00\x00\ -(\x91u\x91\xcbKBA\x14\x87\xbf\xb4\xb2'\x06E\ -\xb4h!Q\xad4\xac@j\xd3B\xe9\x05\xd5B\x0d\ -\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\x06m\x83\x82\xa8\ -M\xafE\xfd\x05\xb5\x0dZ\x07AQ\x04\xd1\xb6\xd6E\ -mJn\xe7j\xa0D\xce0\xe7|\xf3\x9bs\x0e3\ -g\xc0\x12L)i\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\ -\x0b\xa1E\x87\xed\x85F:\xa9\x17k\x09+\xba:\x1b\ -\x98\x08Ru|\xdeSc\xfa[\x97Y\xabz\xdc\xbf\ -\xa39\x1a\xd3\x15\xa8i\x10\x1eST-+<%<\ -\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\ -\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\xfcm\xb2\x16\xf4\ -\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\xa4\x96\x16\x96\x97\ -\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89e\xe6\x03\xe2{\ -du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\x87\x87AF\xc5\ -zp1\xc4\x80\xec\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8a\ -X\x95<\x1a+$H\x92\xc5)jN\xaa\xc7\xc4\xc7\ -E\x8f\xc9L\x917\xfb\xff\xb7\xafz|x\xa8T\xbd\ -\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(l\x19\xc6\xd7\x91\ -a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea!\x8c|\x88\xbe\ -U\xd6z\x0f\xc0\xbe\x01\xe7We-\xb2\x0b\x17\x9b\xd0\ -\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!\ -h\xbf\x81\xa6\xa5R\xcf~\xcf9y\x80\xe0\xba|\xd5\ -5\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00\x04\x5c(4#\xe4\xb1\xdc\x98\x92\x8c\x16\ -@\xc7\xfb\xce\x07\xe8\x09\xfc\xd8\x07\xb91%\x19\x15\xe0\ -\x1fU\x0b~\xb6\xb1\x08cS\xb1/\xb2\x13\x85\xa4R\ -\x00i\xea)\xc0/T\xcb\xaf\xfe\xac\x88\xd5\x9f\x9dp\ -R\xc5/\xec:\x92\x86\xa4\xfag\x80\xf1@7\x95\x82\ -\x95Ua6|Q|\xe6u\xbb\xb6J\xf7B\x160\ -\x0eX\x1da\xfb\xe2\x8e\xa4\xb2\x008\x98\xa9o\xf8\xbc\ -\x84\xd3\x15aNW\x84\xd9\xf0y\x89/u$\x02I\ -\xa3\x00\xd2\xd4\xdb\x007\xa9\x96_m\xd4\x99\xfeO\x0c\ -G\xc3\xc0T\xbb\xae\xa4 i\x14\x00\xb8\x06\xe8\xa2R\ -\xf0tE\x98\x8d_\xd4\xdd\xf5\x1b\xbf,\xa1\xa2By\ -\x91\xaf\x0b\xd6\xce\xa2\xa4 \x99\x14@y\x86\xbenS\ -1\x95Uu\x1d^Q\x19f\xdd\xe6\x96\xf94\x90\x14\ -\x0a M\xbd\x1d0E\xb5\xfc\xea\x06L~C\x7fk\ -\x82)\xd2\xd4\xdb:\xb9 ^I\x0a\x05\x00~\x06t\ -R)X~*\xc4\xe6\xad\xe7O\xfa6m)\xa1\xfc\ -\x94\xf2\x22_\xa6]g\xc2\x93,\x0a\xa0<376\ -\x15S]}\xfe\xde\x8e\xeaj\xe9tQ()\x9e\x06\ -\x12^\x01\xa4\xa9\xb7\x07&\xab\x96oj\xe1\xc7\xe1\xa2\ -\xd0di\xea\xa9N.\x88G\x12^\x01\xb0\xb6zw\ -P)XZ\x16\xe2\xcbm'\x1b\xfd\xfc\xcbm')\ --S\x1e\x062P\xdff\x1e\xb7$\x83\x02(\x9b\xe2\ -O7\x14Q]\xd3\xf8\xd6\xbe\xea\x1a\xc9g\x1b\x8b|\ -\xa9;^Ih\x05\x90\xa6\x9e\x0e\xdc\xa0Z^e\xa6\ -\xefp\x18\xf8\xb94\xf54'\x17\xc4\x1b\x09\xad\x00\xc0\ -$@\xa9\x03\x8aKj\xd8\xfau\xf31\x1e[\xbe.\ -\xa5\xe4d\x8dj\xfdiv\x1b\x12\x96DW\x00e\x13\ -\xbcvC\x11\xa1P\xf3;\xbbC!\xc9\xda\xf5-g\ -\x18HX\x05\xb0C\xbe\xaeW-\xefd\xbd\xdf\xe1\xa2\ -\xd0$\xbb-\x09I\xc2*\x00p#\xa0\xf4\x18v\xa2\ -\xa8\x9am;\xca\x94\x05o\xdbQ\xc6\x89\xe2j\xd5\xe2\ -\xa9\xc0\xcf\x95\x85\xc7\x19\x89\xac\x00\xca\xa6w\xcd\xfa\x22\ -\xc2a\xf5\xc0\x9epX\xb2v]\xcb\x18\x06\x12R\x01\ -\xa4\xa9g\x02\xd7\xaa\x96w8\xb3\xb7\xaeq6\x0c\x5c\ -k\xb7)\xe1HH\x05\xc0Z\xf9S\xf2\xc9\x17\x1e\xaf\ -b\xc77\xea\xe6\xbf\x96<\xb3\x8c\xc2\xe3U\xaa\xc5\xdb\ -b\x0dI\x09G\xa2*\x80\xb2\xc9\xfd\xc4(\xc2\x81\xf5\ -?CX\xc2\x9a\x160\x0c$\x9c\x02HS\xbf\x00\xf8\ -\xa9jy\x87\xa6\xdc\xcd\xb5?\x93\xa6\xae\xb4!%\x9e\ -H8\x05\xc0\xf2\xfb+mf=|\xb4\x8aovG\ -\x9e\xe5\xc5\xdcU\xce\x91B\xe5a \x05\x07{\x12\xe2\ -\x85DT\x00\x07\xe6\xff\x04\xd2ET\xbf\x94\x8e\xf7\x0b\ -&\xdc0\xe0\xf9\xb6p;27\xa5\xdeO\xb0\x89\xf7\ -M}\xd6P\xd9\xf6\xc0\x04\xd5\xb6\xb81\xffu2\x8a\ -\x98v\x93r\xa0\xd1\x04i\xea3\x80\xd3@\x08\xa8\xb1\ -\x7f\xea\xbfn\xee}\x93e\xbd\x8ePnP\x01\xec\xec\ -\x1aWc\x85Y]\x86\x95O/\x0d\xb5\x8e\x8a\x0b\x0e\ -\x1c\xaad\xf7\xdeS\xae\xe5\xec\xfa\xb6\x9c\x83\x87+\xc9\ -\xee\xd1N\xa5x\x10x\xcbu\xa5M M\x1d\xd4\x94\ -\xe5\x14V\xfe\xc3\x1dX\x09\xaf\xd64\x94\xe5\xe4\xbc\x0e\ -\x93\xa6\xfec\xe0\xf7$x\x1c\xdcj\x97\xe6\xbf\x16)\ --Y3n\xee\xe9^\x98w\xa8\xdel\x97Q\x17)\ -eHS\xff\xad\xd0\x8c\xf5\xf5\x0b\x9c5\x07\x90\xa6\xfe\ -\x18\x96\xb6$t\xe7Cd\x8b?\x8d\xcbr\xf48\x18\ -\xaf\xe8\xc0:i\xea\xbf\xa9\xff\xc73\x0a M}6\ -\xb0 \xda\xad\xf2\x03sW9\x05\xfbN{&o\xef\ -\xf7\xa70](\x08\x06\xea\ -d\xb6i#\xb8l\xa0R|L}\xd6\xa4\x00\xbbp\ -\xa8\x00UU\x92\xb9/\x16p\xa2Hy\xdf\x5cR\x13\ -\x96p\xf4X\x15G\x8f\xc5fnpA\xe76\xf2\x99\ -\xc7\xfaD\x92\xf2oW\x00\xc8sz\xd5\xf0\xa1\x19\xe4\ -.\xd1\x181,a7\xc3&\x0d#\x86e\xc8\x15\xaf\ -hb\xf8\xd0\x8e\x91\x5c\x9e'\xa4\xa9w\xc2\xca\xab\xab\ -\x14^]\x1f)a\xc5\xbb\x07\xc9}\xef\x90\xa3M\x97\ -\xad\xb8'\x10\x10\xdc\xf9\xcb\x9er\xe6\xb4\x9eB\x88\x88\ -\xf2}\x16\x03\xbd\x03B3J\x80\x97#\x91 \x04\xcc\ -\xba5\x9bE\xf3\xfb\xd3%3i\xd2\xe6\xc4=]2\ -\xdb\xf0\xd2\xb3\xfd\xe5\xac[\xb3#\xed|\x80\x97\x85f\ -\x94\xd4N]_\xc2r\x1bF\xc4\x95\x97w$w\x89\ -\xc6\x15CZ\x87\x04\xbf\xb9bH\x06+\x96h\x8c\x18\ -\xd6\xd1M\x9a\xdf<\xec\x9b\xfe\x8c\x10i\xea\xdd\x81O\ -\x81\x01\x91J\x0dKX\xfe\xce\x01\xde^u(nf\ -\xe8\xc9B \x98qs\x0fy\xd7\xf4l\x11\x08\xb8\ -J\xf1\xbc\x13\x18/4\xe3\x08\x9c\x93,Z\x9az/\ -\xe0\xaf\xc0p75|\xf1\xd5I\xe6/,\xf0uU\ -\xae%\xd193\x85\xa7\x1f\xed\xc3\xa8+\x1cO\xd3\xce\ -e+p\xa3\xd0\x8c\x03\xb5\x7f8O\x95\xec\xac\x17\x0b\ -\x81_\xb9\xa9\xe9\xd8\x89j\xe6.(`\xdb\x8e\xa4>\ -u\xcdw.\xbf,\x83\xb9\x8f\xf7\xa1k\x17\xd7\xfe\x87\ -\xa5\xc0\xa3B3*\xeb\xff\xb1Q[\x22M\xfd\x16\xe0\ -\x0d\xacL\x18\x11\x11\x96\xf0\xc6[\x07x\xe7\xbfZ\x87\ -\x04\xa7\x04\x04\xdcvsO\xee\xbe-\x1b\x97&\xbf\x14\ -\xb8[h\xc6\xfb\x0d}\xd8\xa4di\xea\xfd\xb1N\xdc\ -\xbc\xdcM\x0b6o)a\xfe\xc2\xef\x9c\xc4\xdd\xb7h\ -2;\xa5\xf0\xf4\xec>\x5c5\xc2\xb5\xc9\xdf\x86u\x02\ -\xea\xb7\x8d\x15hV\xb5\xec!\xe1%\xe0~7-)\ -<^\xc5\x9c\x17\x0a\xc83\x93\xca\xa7\xee9C\xb5\x0e\ -\xcc{\xbc/\xdd\xba\xba6\xf9\xaf\x01\xb3\x85fT4\ -UH\xd9\xb6HS\x9f\x06,C1!SC\x84\xc3\ -\x92\xd7W\x1e\xe0\xdd?\x1dn\x1d\x12\xce! `\xda\ -\xd4\x1e\xdc;\xa3\x17\xc1\xa0+\x93_\x06\xdc#4C\ -\xe9\xd4SG5IS\x1f\x805$\x0c\x8d\xa0ag\ -\xd8\xf0E\x09\xcf-\xfa\x8e\x92\xd2\xd6!\x01\xa0SF\ -\x0aO=\xda\x87\xd1W\xba6\xf9_c\x99\xfc\xdd\xaa\ -\x178V5;/\xdfb\xe0\x1e\xa7\xd7\xd6\xe7\xe8\xb1\ -*\xe6<_\xc0\x8e\x9d-{H\x182\xa8\x03\xf3\x9e\ -\xe8K\x96{\x93\xbf\x0cxXh\x86\xa3M\x11\x11\xdb\ -\x1ai\xea\xd3\x81\xd7\x81\xf4He\x84B\x92\xd7r\xf7\ -\xb3\xea\xbf\x8fx\xba\x893\x11\x08\x08\xb8eJ\x0f\xee\ -\xbb\xa3\x17)\xeeL~9p\x9f\xd0\x8cw\x22\xb9\xd8\ -U\xcd\xd2\xd4\x07\x02\xff\x89\x15\x80\x101\xeb?/\xe6\ -\xb9\x97\xbe\xe7d\x0b\x19\x12:f\xa4\xf0\xd4#}\x18\ -3\xca\xb5\xc9\xdf\x81e\xf2wF*\xc0\xf5\xb1qv\ -\x9e\xbcW\x80\xbb\xdc\xc89RX\xc5\x9c\x17\xf6\x92\xbf\ -3i\xf6\xdf7\xc8\xe0\x81\xe9\xcc\x7f\xa2\x1f\xdd\xbb\xb9\ -6\xf9\xcb\x81\x87\x84f\xb8\x8a\x7f\xf3\xec\xdc@i\xea\ -\xb7c=zD\x9c81\x14\x92\xbc\xbab?\xef\xff\ -%\xf9\x86\x04!\xe0\x96\xc9\xddy`f\x8e\xdbY\xfe\ -)\xe0~\xa1\x19o{\xd2./\x84\xd4\x22M]\xc3\ -\x1a\x1247r\x8cM\xc5\xcc\xfbC\x81\x93S<\xe2\ -\x9a\xd4\xd4\x00\xcf\xfc\xa6/\xfa\x8f\x5c\xa7\x11\xca\xc72\ -\xf9\xdfx\xd0,\xc0\x87\x93C\xed\xf4\xadK\x81;\xdc\ -\xc8\xd9\x9e_\xc6\xe3\xf3\xf68\xc9\xe1\x1f\x97\xa4\xa7\x05\ -yqn\x7f\x86j\x11/\x9f\xd4\x92\x0b<(4\xc3\ -\xd31\xd2\xb7\xa3c\xa5\xa9\xdf\x09\xbc\x8a\x15\xd3\x1f\x11\ -_\x9be<\xf8\xe4\xae\x84\xddm\x14\x08\x08\xfe\xf8\xfc\ -\x00\xb7\x9d\x7f\x1a\xf8\x95\xd0\x8c\x5coZu6\xbe\xed\ -e\xb6\x1b<\x0a\xcb\xff\x1c\x11C\xb5\x0e\xdc4I\xe9\ -\x18\xc0\xb8d\xea\x0d\xdd\xdcv\xfe7\xc0H\xbf:\x1f\ -|N\x11#4c\x070\x12\xf8\xf7He\xdc{{\ -/:g\xc6M\xde\x09e:g\xa6p\xef\xed\xaeN\ -\xb0\x7f\x1b\x18%4#\xdf\xa3&5\x88\xef\xd1\x0cB\ -3\xca\x80\xdb\x81\xbb\x81&\x1d\x13\x0d\x91\xd6>\xc8\xf8\ -1\x9d=o\x97\xdf\x8c\x1f\x1bq\xb0H\x05\xd6#\xf5\ -\x1d\xf6w\xe7+Q\x09g\x11\x9a!\x85f,\xc7\x1a\ -\x12v9\xbd~\x5c\x02*\xc0\xb81\x11\xcd\xf8wa\ -\xdd\xf5o6\x94\xce\xc5\x0f\xa2\x1a\xcf$4#\x0fk\ -H\xf8\x0f'\xd7\x0dv\x1e\xf1\x12s.\x1b\xe0\xb8\xcd\ -\xef\x00W\xda\xdfQ\xd4\x88z@\x9b\xd0\x8cR\xe06\ -\xe0>\xa0\xb2\x99\xe2\x00\xb4O\x0d\x90\x92\xe2\xdb\x03\x8b\ -\xe7\xb4I\x11\xa4\xaa\x9b\xffJ\xe0^`F4L\xfe\ -\xb9\xc4$\xa2\xd1\x1e\x12\x96\x01\x1f\xaa^\x93\x9e\x16\xf4\ -\xb1E\xde\x92\xe6\xa0\xad\xe1\xb0\xfcPh\xc6\x1b\xd12\ -\xf9\xe7\x12\xb3\x90V\xfb\xb4O\xa5\x94\xaf\x15\x15aJ\ -\x13\xc8QTZZ\xa3\xbc\x8a\x19\x08\x88\x9f\xda\xdfE\ -L\x88eL\xf3\x04\x14w\x17\x15\xec;\x9dP;\x88\ -\xc2\x12\x0a\xd4s\x15t\xc0\xca\xc9\x18\x13b\xa9\x00\xca\ -\xe9\xd5\xf3w%\x9e\x87\xd0t\xd6\xe6\x98\xa5\x9a\x8f\x89\ -\x02\xd8\xe9d\x95\xffi\x87'{\xc7\x05\x0e\xdb|\xa3\ -\x9d\xac+\xea\xc4\xca\x02\x8c\x00\xb2U\x0a\x96\x95\x87\xd8\ -\x9e\x9fx\xc1%\xdbv\x94RV\xae\xec\xc8\xea\x85\xf5\ -\x9dD\x9dX)\x80\xf2Y\xbf\x9b\xbe,\xa1\xa6\x89\xd3\ ->\xe3\x95\x9a\x1a\xc9\xa6-\x8e\xd2\xd5)\x7f'^\x12\ -+\x05P6\xff\x0eO\xf4\x8e+\xd69k{L\xe6\ -\x01QW\x00i\xea}\x80!*ekj$\x9b\xb7\ -z\x9f\xf41Zl\xdaR\xa2tX\xa5\xcdP\xfb\xbb\ -\x89*\xb1\xb0\x00\xca\xa6\xee\xab\x0c\xf8\xaa\x00\xb6cC\xdd\xf9\xd3\xcc\x97\ -#\x04L\xba\xa6+\xb9K\x06\xa3\x0d\x8887U-\ -?`-NM\xb0_G\xcc\xe0\x81\xe9\xacxE\xe3\ -\xfak\xba\xd2\x5c\xfa\xfexs\x0e\xf9m\x01\x1c9\x7f\ -\x8e\x146\xee\xfc\xe9\x98\x91\xc2\xb3O\xf6\xe3\xb7\x0f\xf7\ -\xf6\xe2\x84\xae\xf7\x80aB3>\x15\x9a\xb1\x16\xeb\xd4\ -\xb4Un\x04\xa6\xb5\x0f\xf2/\x0f\xf7f\xfe\x93\xfd\xe8\ -\x98\xd1\xf8\x02\xeb\xe1\xa3\x8e\x9dC\xd7\xb9iWs\xf8\ -\xad\x00\x9e\x98\xff\x11\xc3:\xb2\xf2\x8f\x1a\xe3\xc7\xba\xbe\ -\x19J\x81\x19\xc0\xadB3\xce\x1c\x09n\xbf\x9e\x86\x15\ -\xc5\xecj\x07\xea\xd5c;\xb3\xb2\x99\xf3\x94\xe2i\x18\ -\xf03C\x88#\xe7\xcf\xac\x7f6\xd9\xbd\xf7\xec;\xa3\ -M\x1b\xc1}\xb7\xe7\xf0\xcb)\xdd\xbdh\xd2z\xac\xf8\ -\xbb\xef\x9a*d?\xb5\xbc\x0d\x8cu[\xe1\xbb\x7f:\ -\xcc\xb2\xb7\x0fP]}\xf64#\x9e\x9cC~Z\x80\ -\xf1(v\xfe\xd1c\xe7\x9b\xc5\xde\x17\xb5\xe7\x8dE\x83\ -\xbc\xe8\xfc\x10\xf04\xd6)\x19Mv>\x80]f<\ -0\xc7\xbe6b\xa6\xddd-G\xf7\xbe\xf0\xec\xe5\xe8\ -Z\xe7\x90\x22\x19\xc087\xedh\x0a?\x15\xc0\xd1\xe2\ -O\xad\xf3G\x08\x98zC\x16\xcb_\x1e\xc4%}\x5c\ -;b\xbe\x05\xc6\x0a\xcd\xf8\x9d\xd0\x0c\xe5\xe0B\xa1\x19\ -5B3\x9e\xc5\xb2\x02{\xdd4\xa0\x7f_\xcb!u\ -\xd3\x0dYg&\x88R\xc2\xfa8\x19\x06|Q\x00\xc7\ -\xce\x1f\xfb\xcb\xe8\x92\xd9\x86\x05s.\xe1\x91\xfb/\xa2\ -][\xd7M[\x0e\x5c!4cs\xa4\x02\xeck/\ -\xb7eEL\xbb\xb6\x01f\xdf\x7f\x11\x0b\xe6\x5cr\xe6\ -t5\xa7{\x05\xfdr\x0e\xf9e\x01\x86c94\x9a\ -\xa5\xfcT\x88my\xa5\x8c\x19\xd9I\xae\x5c\xaa1z\ -\xa4\xeb\x5cz'\x80\xa9B3\xee\xf6\x22\xde^hF\ -\x99\xd0\x8c\xbb\x81\xa9\xb6\xec\x88\x19=2\x93\x95K5\ -\xc6\x8c\xea\xc4W\xce\x9cC9\xc0\x15n\xean\x0c_\ -\xb4J\x9a\xfa|\xacq\xb7Y\xd6m.\xa6\xf0x\x95\ -\x9cr}\x96\x17m\xf9\x18+\xb7\xceA\x0fd\x9d\x87\ -}\xa8V.p\x8d[Y\x7f\xfe\xe0(Y\xdd\xda2\ -v\x94\xb2\xc2\xcf\x17\x9a\xf1\x8c\xdbz\xcf\xc5/\x05X\ -\x8b\x8f\x13\x97\x06\xa8\x02\x9e\x04\x16\x0b\xcd\xf05\xbd\xa8\ -\x1d\xd8\xfa0\xf0{\xc0u\xc2_\x07\xac\x15\x9a\xe1y\ -\x18\xb9_[\xc2.\xf1InC\xe4c=\xd7\x7f\x1d\ -\x8d\xcal\x05[$M}5\xd6\xe6\xcd\xc1\xd1\xa8\x17\ -\x9f\xbeS\xcf\xe7\x00v\xf6p\xa5\xf1\xdf\x03^\xc1J\ -\xa4\x18\x95\xce\xaf\x8f\xd0\x8c\xedX\x09\xaf\x96D\xa9\xca\ -\x1c\xfb\xbb\xf5\x14?,@4\x1cLG\x80;\x85f\ -\xfc-\x0au5\x8a}:\xc7C\xd2\xd4?\x02V\x00\ -\x9e\xacX5\x81\xe7\xdf\xad\xe7\x02\xed\x99\xf7>\xaf\xe5\ -\xd6\xe3\xaf\xc0\x90Xw~}\x84f|\x84\xb5\xe7\xe1\ -\x7f|\xac\xe6{?\xb2\x88\xf9u\xb7F|\x10u\x13\ -\x9c\xc6:\xba\xee\x1f\x84f\x14\xfa \xdf\x15v\x9b&\ -c\xb5\xd1\xd1\xb9=\x8a\xf8\x922\xd6/\x05p\x94\x08\ -R\x81-X\x8b:\xaf\xc7*\x9d\x9a\x0av\xfa\xbb\xd7\ -\xb1\xd6A\xb6z,\xde\x97h!\xbf\x14`\x15`z\ - Gb=n\x8d\x11\x9a\xe18\xc5l\xac\xb0\xcf\xf0\ -\x19\x0d<\x8f\x8b\x0d'\xf5\xc8\xc7\xa5\xbb\xba1\xfc\xf4\ -\x06^\x0f\xfc\xaf\x8b:~\xc0\xf2\xde}\xea]\xab\xa2\ -\x8f4\xf5qX\xde\xc5\x0b#\x15\x01\xdc 4C9\ -\xa9\xa6\x13\xfca\xcb\ -\x00\x00\x05\xc9iTXtXML:com.\ -adobe.xmp\x00\x00\x00\x00\x00\x0a\x0a \x0a \x0a \x0a \ - \x0a \ - \x0a \x0a \x0a \ - \x0a <\ -/xmpMM:History>\x0a\ - \x0a \ -\x0a\x0a\x14o\x1eC\x00\x00\x01\x81iCCPsRG\ -B IEC61966-2.1\x00\x00\ -(\x91u\x91\xcbKBA\x14\x87\xbf\xb4\xb2'\x06E\ -\xb4h!Q\xad4\xac@j\xd3B\xe9\x05\xd5B\x0d\ -\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\x06m\x83\x82\xa8\ -M\xafE\xfd\x05\xb5\x0dZ\x07AQ\x04\xd1\xb6\xd6E\ -mJn\xe7j\xa0D\xce0\xe7|\xf3\x9bs\x0e3\ -g\xc0\x12L)i\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\ -\x0b\xa1E\x87\xed\x85F:\xa9\x17k\x09+\xba:\x1b\ -\x98\x08Ru|\xdeSc\xfa[\x97Y\xabz\xdc\xbf\ -\xa39\x1a\xd3\x15\xa8i\x10\x1eST-+<%<\ -\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\ -\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\xfcm\xb2\x16\xf4\ -\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\xa4\x96\x16\x96\x97\ -\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89e\xe6\x03\xe2{\ -du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\x87\x87AF\xc5\ -zp1\xc4\x80\xec\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8a\ -X\x95<\x1a+$H\x92\xc5)jN\xaa\xc7\xc4\xc7\ -E\x8f\xc9L\x917\xfb\xff\xb7\xafz|x\xa8T\xbd\ -\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(l\x19\xc6\xd7\x91\ -a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea!\x8c|\x88\xbe\ -U\xd6z\x0f\xc0\xbe\x01\xe7We-\xb2\x0b\x17\x9b\xd0\ -\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!\ -h\xbf\x81\xa6\xa5R\xcf~\xcf9y\x80\xe0\xba|\xd5\ -5\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00J\x03\x13\xc0\ -h\x22\x95\x5c8\xa8\xe1{\x030\xdb\xdb\x7f\x0c\xf8\x0e\ -\xb8\x0a\xc4<\x1d\x9e\xf0K\x06\x18\x03n%R\xc9\xad\ -\xfd\x1a\xec\x1b\x00\xfb\xa8\xbf\x0d|Z\xbe\xb1\x09\x1fM\ -\x01_\xedw6\xf8_\x00f{\xfb?\x01&\x81N\ -\x1f\x06&\xfc\xf3\x12\x18N\xa4\x92\xd3\xbb\x7f\xf8\x9f\x00\ -\xd8G\xfe#\xa4\xf8A5\x07\xf4%R\xc9\xc5\xe2\x0f\ -B\xc5\xbf\xd8\xd7\xfc\xdbH\xf1\x83\xac\x0b\xb8m\xd7\x1a\ -\xd8\x15\x00\xac\x1b>\xb9\xe6\x07\xdfy\xacZ\x03\xf6%\ -\xc0>\xf5\xff\x89\xdc\xed\xeb\x22\x03|\x94H%\x17\x8a\ -g\x80\xebH\xf1u\x12\xc3\xaa9\x86=\xc3\xb7\x0c\xc4\ -\xdd\xec!\xb3\x93g&\xfb\x8e\xd5\x5c\x8e<\x852\x8c\ -Q811h\x0a\x87\xe9\x8e\xd6\x12\x0b\x99n7O\ -\x03\xeda\xac\xe9]\xe5\xe2\xaf\xed\xe4\x19_\x9a\xe3\xfe\ -\xdak\xb6\x0bR\xf8\xa3 b\x18\x0c\xc6[\x18i\xef\ -\x22\xae\x1e\x84\x06` \x8c5\xb7\xafdm'\xcf\xc8\ -\xdc\x1f\xccf7K\x19\xa7(\x93\xedB\x81_\xd2+\ -<\xdd\xcc0\xdeu\xc6M\x08.\x87\xb0\x1e\xec(\x19\ -_\x9a\x93\xe2\x1fa\xb3\xd9M\xc6\x97\xe6\xdclr6\ -\x84\xf5T\xcfQf'\xcf\xfd\xb5\xd7%\x0dL\xf8\xe7\ -\xfe\xdak2\xf9\xbcj\xf3\x8e\x10\xd6#]G3\xd9\ -wr\xcd\xaf\x02\xdb\x85\x02/\xb2\xefT\x9bw\x84P\ -|\x9e\xbf\x9a\xcb\x95<(\xe1\xaf7\xf9\x9c\xea\x91Z\ -\x1frnc\x91\x8fz\xd5#\xef\xe2L\xad\x1c\x00\x11\ -L\x12\x00\xcdI\x004'\x01\xd0\x9c\x04 \x88\x0c\xf5\ -;v\xe5\x00\x98\xeao\x90\x8b\x0asS\xab\x10\xd6{\ -\xe5\x8e\x9a\xccp\xa9\xe3\x11>k6\xc3\xaa\x09X\x0f\ -a-*p\xd4\x1d\xad%b\xc8Y\xe0\xa8\x8b\x18\x06\ -\xa7\xa2\xb5\xaa\x85\x9a\x0fa\xad(q\x143M\x06\xe3\ --\xa5\x8fL\xf8b0\xdeB\xccT~\x1a8\x1f\xc2\ -ZN\xa4d\xa4\xad\x93D\xb4\xa6\xa4\x81\x89\xf2\xfb \ -Z\xc3\xb7\xed]n6y\x1a\xc2ZK\xa6$n\x86\ -\x19\xef\xeca(\xdeZ\x90\xcb\xc1\xd1\x111\x0c\x86\x1a\ -Z\x19\xef:C\xbd\xbb7\x83\xee\x14_\x09[\xc2\xe5\ -\xba\xbf\x5c\xa1@\x81\x02\x11C>IV\xd2v\xa1@\ -\x080\xdd\x1f\x90i\xa0=d\xaf\x22\x9dp\xbbu\xd8\ -0\xa4\xf8G@\xc40J)>\xc0D\x22\x95\xcc\x16\ -+8\x8a\xf5\xaa\xb0\xd0C\x06\xab\xe6\xd6D\x90\xbdh\ -p\xac\x92#\x12\xbe\x1a+.\x14\xdd}\x0e\xbf\x85\xb5\ -\x8aT\x04\xdb\x14V\xad\x01Y\x1c\xaa\x9b\xf7/\x0e\x85\ -\x7f/\x05\xc3XK\x89E\xb0\xcc\x01\x97v\x17\x1f\xf6\ -y\x18d\xaf\x1f\xef\x03\x1e\xfa40Q~SXG\ -\xfe\xf4\xde\x7f\xd8\xf7s\x9c}&\xb8\x88}\xa7(\xaa\ -\xda\x0d``\xef\x91_\xa4\xf2%Q\xf26h\x15K\ -\xa4\x92\x07\xd6Xfr4\xe7\xd9C\xfe\xdf\xd7\xdf\xf0\ -\xfd\xab\xbf\xbc\xda\x9d8\xc0\x8d\x93\x1f\xf2Y}\xb3'\ -\xfb\x923\x80\xe6$\x00\x9a\x93\x00hN\x02\xa09\x09\ -\x80\xe6$\x00\x9a\x93\x00hN\x02\xa09\x09\x80\xe6$\ -\x00\x9a\x93\x00hN\x02\xa09\x09\x80\xe6$\x00\x9a\x93\ -\x00h.0\x8b\xfe\x0d`\xb8\xb1\x8d\xfeX\x13\xa7\x8f\ -\xd5\xd1\x1a\x8ex\xba\xff\x95\xdc6\xcf\xb76HfV\ -\x99|\xbb\x1c\x98/\xcd\x0bD\x00Z\xc3\x11\xae\x1d\xef\ -\xe6\x5c\x9d\xabo\xbcw\xdd\xc7\xf9p#\xe7c\x8d\x0c\ -\xd6\xb7psq\x86\x95\xdcv\xd9\xfa\xf3K\xd5_\x02\ -\x0c({\xf1\xf7:W\x17\xe7\xda\xf1\xee@|iN\ -\xd5\x07`\xb8\xb1\xcd\xd7\xe2\x17\x9d\xab\x8b3\xdc\xd0\xe6\ -{\xbf^\xab\xfa\x00\xf4\xc7\x9a*\xd6\xf7\x85\xfa\xca\xf5\ -\xed\x95\xaa\x0f\xc0\xe9cu\x15\xeb\xbb\xa7\x82}{\xa5\ -\xea\x03 \x0e\xa7\xea\x03\xf0|k\xa3b}?\xab`\ -\xdf^\xa9\xfa\x00$3\xab\x15\xeb\xfb\xc1z\xe5\xfa\xf6\ -J\xd5\x07`\xf2\xed2\x8f7\xd6|\xef\xf7\xf1\xc6\x1a\ -\x93\xe9e\xdf\xfb\xf5Z\xd5\x07\xa0\x00\xdc\x5c\x9c\xf15\ -\x04\x8f7\xd6\xf8aa&\x10\xb3\x81\x81\x98\x09\x5c\xc9\ -ms\xf5\xef\xe7\x0c7\xb4q\xa1\xbe\x89\x9e2M\x05\ -?\xdb\xda\xe0\xc1\xfa*\x93i\x99\x0a>r\x0a\xc0\xbd\ -\xf42\xf7\x02pZ\xf6S\xd5_\x02\xc4\xe1H\x004\ -'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x004'\x01\xd0\ -\x9c\x04@s\x12\x00\xcdI\x004'\x01\xd0\x9c\x04@\ -s\x12\x00\xcdI\x004'\x01\xd0\x9c\x04@s\x12\x00\ -\xcdI\x004'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x00\ -4'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x004\xe7Y\ -\x00\xcc@|eRu\xf0\xf2w\xad\x12\x80u\x95\x1d\ -5\x99\x81Yfx\xe45\xab/|u\xac\x9dJ\x00\ -^\xa9\xf4\xd4\x1d\xad%\x8c\x11\x94E\xb3GV\xc40\ -8\x15\xadQm>\xef\xd4@%\x00\x8e;\x01\x88\x99\ -&_4\xb4H\x00\xcal0\xdeB,d\xaa6\xf7\ -$\x00OT{\x1bi\xeb4\x12\x91\x9a\x1d\xd5\xf6\xc2\ -\x9d\x0f\xa25\x8c\xb4u\xba\xd9\xe4\xa9S\x03\x95\x00\xdc\ -U\xed-n\x86\x8d\xf1\xae\x9e\xd0P\xbc\xb5\x101\xe4\ -\xa6\xd0+\x11\xc3`\xa8\xa1\x95\x1f;{\x88\xbb\xbb\xd7\ -\xba\xe3\xd4@\xe5\xbf\x8f\x8f\x02K@\x83\x9b\x9e3;\ -y^d7y\x93\xdb&\x1f\x98\xef\xd3\xf0\x97\x89A\ -s8\xc2\xa9h\x8d\x9b\xd3~Q\x1ahO\xa4\x92\xd9\ -\x83\x1a)\x1d\xa6\xb3\xbd\xfd?\x01_\xbb\x1d\x81\xa8\xa8\ -\x9f\x13\xa9\xe47N\x8dT\xe7\x01F\x81\xcc\xe1\xc6#\ -|\x94\xc1\xaa\x99#\xa5\x00$R\xc9\x05`\xec0#\ -\x12\xbe\x1a\xb3k\xe6\xc8\xcdL\xe0-`\xaa\xb4\xf1\x08\ -\x1fMa\xd5J\x89\xab[\xf5\xd9\xde\xfe\x13\xc0#\xc0\ -\xd5g\x11\xe1\x9b9\xa0/\x91J.\xaan\xe0\xeaY\ -\x80}Z\x19\x06^\xba\x1c\x98(\xbf9\xe0\x92\x9b\xe2\ -C\x09\x0f\x83\x12\xa9\xe44\xd0\x074\xb5\x8e\xf5Pg\x1ekn\xff\x0e\xf0\ -\x9b\xd3\x0c\x9f\x10B\x08!\x84\x10B\x08!\xc4^\xff\ -\x00u\xf7\x9a\xe8\x02\xe0|U\x00\x00\x00\x00IEN\ -D\xaeB`\x82\ -" - -qt_resource_name = b"\ -\x00\x06\ -\x07\x03}\xc3\ -\x00i\ -\x00m\x00a\x00g\x00e\x00s\ -\x00\x18\ -\x07\xf3~\xc7\ -\x00s\ -\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\ -\x001\x002\x008\x00.\x00p\x00n\x00g\ -\x00\x17\ -\x01\xdaN\xc7\ -\x00b\ -\x00o\x00x\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\x001\ -\x002\x008\x00.\x00p\x00n\x00g\ -\x00\x17\ -\x00\xe8\xfcG\ -\x00s\ -\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00e\x00m\x00p\x00t\x00y\x00_\x001\ -\x002\x008\x00.\x00p\x00n\x00g\ -\x00\x16\ -\x03\x0aoG\ -\x00b\ -\x00o\x00x\x00_\x00i\x00c\x00o\x00n\x00_\x00e\x00m\x00p\x00t\x00y\x00_\x001\x002\ -\x008\x00.\x00p\x00n\x00g\ -" - -qt_resource_struct = b"\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ -\x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x02\ -\x00\x00\x00\x00\x00\x00\x00\x00\ -\x00\x00\x00|\x00\x00\x00\x00\x00\x01\x00\x005'\ -\x00\x00\x01\x93MeU\xc5\ -\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\ -\x00\x00\x01\x93MeU\xc3\ -\x00\x00\x00\xb0\x00\x00\x00\x00\x00\x01\x00\x00O\x02\ -\x00\x00\x01\x93MeU\xc3\ -\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x93MeU\xc5\ -" - -def qInitResources(): - QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) - -def qCleanupResources(): - QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) - -qInitResources() diff --git a/src/tagstudio/qt/views/item_thumb_view.py b/src/tagstudio/qt/views/item_thumb_view.py new file mode 100644 index 0000000000..3031fc6f0e --- /dev/null +++ b/src/tagstudio/qt/views/item_thumb_view.py @@ -0,0 +1,167 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +from collections.abc import Iterable +from typing import NamedTuple + +from PySide6.QtCore import Qt +from PySide6.QtGui import QAction +from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget + +from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from tagstudio.core.utils.types import unwrap +from tagstudio.i18n.platform_strings import open_file_str, trash_term +from tagstudio.i18n.translations import Translations +from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.views.styles.stylesheets import ( + item_thumb_check_badge_style, + item_thumb_filename_style, + item_thumb_label_style, +) + + +class _BadgeStyle(NamedTuple): + name: str + icon: str + icon_checked: str + + +_BADGE_STYLES: dict[int, _BadgeStyle] = { + TAG_FAVORITE: _BadgeStyle("Favorite", "star_icon_empty", "star_icon_filled"), + TAG_ARCHIVED: _BadgeStyle("Archived", "box_icon_empty", "box_icon_filled"), +} + +_ICON_SIZE = 24 + + +class ItemThumbView(QVBoxLayout): + FILE_LABEL_HEIGHT = 12 + FILE_LABEL_SPACING = 4 + + def __init__(self, badge_tag_ids: Iterable[int]) -> None: + super().__init__() + self.setContentsMargins(0, 0, 0, 0) + self.setSpacing(self.FILE_LABEL_SPACING) + + # +----------+ + # | ARC FAV| Top Right: Favorite & Archived Badges + # | | + # | | + # |EXT #| Lower Left: File Type or Entry Group Icon + # +----------+ Lower Right: Entry Group Count, Video Length, or Word Count + # + # Filename Underneath: (Optional) Filename + + # Thumbnail ============================================================ + + # +----------+ + # |*--------*| + # || || + # || || + # |*--------*| + # +----------+ + thumb_layout = QVBoxLayout() + thumb_layout.setObjectName("baseLayout") + thumb_layout.setContentsMargins(0, 0, 0, 0) + + # +----------+ + # |[~~~~~~~~]| + # | | + # | | + # | | + # +----------+ + top_layout = QHBoxLayout() + top_layout.setObjectName("topLayout") + top_layout.setContentsMargins(6, 6, 6, 6) + thumb_layout.addLayout(top_layout) + + # +----------+ + # |[~~~~~~~~]| + # | ^ | + # | | | + # | v | + # +----------+ + thumb_layout.addStretch(2) + + # +----------+ + # |[~~~~~~~~]| + # | ^ | + # | v | + # |[~~~~~~~~]| + # +----------+ + bottom_layout = QHBoxLayout() + bottom_layout.setObjectName("bottomLayout") + bottom_layout.setContentsMargins(6, 6, 6, 6) + thumb_layout.addLayout(bottom_layout) + + self.open_file_action = QAction(Translations["file.open_file"], self) + self.open_explorer_action = QAction(open_file_str(), self) + self.delete_action = QAction( + Translations.format("trash.context.ambiguous", trash_term=trash_term()), self + ) + + # Static Labels ======================================================== + + # Item Type Icon ------------------------------------------------------- + # Used for showing the Entry Group icons. + # Mutually exclusive with the File Extension Label. + self.item_type_icon = QLabel() + self.item_type_icon.setObjectName("itemTypeIcon") + self.item_type_icon.setMinimumSize(_ICON_SIZE, _ICON_SIZE) + self.item_type_icon.setMaximumSize(_ICON_SIZE, _ICON_SIZE) + bottom_layout.addWidget(self.item_type_icon) + + # File Extension Label ------------------------------------------------- + # Mutually exclusive with the Item Type Icon. + self.ext_label = QLabel() + self.ext_label.setObjectName("extLabel") + self.ext_label.setStyleSheet(item_thumb_label_style()) + bottom_layout.addWidget(self.ext_label) + bottom_layout.addStretch(2) + + # Count Label ---------------------------------------------------------- + # Used for Entry Group counts, video length, word count, etc. + self.count_label = QLabel() + self.count_label.setObjectName("countLabel") + self.count_label.setText("-:--") + self.count_label.setStyleSheet(item_thumb_label_style()) + bottom_layout.addWidget(self.count_label, alignment=Qt.AlignmentFlag.AlignBottom) + top_layout.addStretch(2) + + # Tag Badge Checkboxes ================================================= + self.badge_container = QWidget() + badge_layout = QHBoxLayout() + badge_layout.setDirection(QBoxLayout.Direction.RightToLeft) + badge_layout.setContentsMargins(0, 0, 0, 0) + badge_layout.setSpacing(6) + self.badge_container.setLayout(badge_layout) + top_layout.addWidget(self.badge_container) + + rm = ResourceManager() + self.badges: dict[int, QCheckBox] = {} + for tag_id in badge_tag_ids: + style = _BADGE_STYLES[tag_id] + badge = QCheckBox() + badge.setObjectName(f"{style.name.lower()}_badge") + badge.setToolTip(style.name) + badge.setStyleSheet( + item_thumb_check_badge_style( + _ICON_SIZE, + unwrap(rm.get_path(style.icon)), + unwrap(rm.get_path(style.icon_checked)), + ) + ) + badge.setMinimumSize(_ICON_SIZE, _ICON_SIZE) + badge.setMaximumSize(_ICON_SIZE, _ICON_SIZE) + badge.setFocusPolicy(Qt.FocusPolicy.NoFocus) + badge.setHidden(True) + self.badges[tag_id] = badge + badge_layout.addWidget(badge) + + # Filename Label ======================================================= + self.file_label = QLabel(Translations["generic.filename"]) + self.file_label.setStyleSheet(item_thumb_filename_style()) + + self.addLayout(thumb_layout) + self.addWidget(self.file_label, alignment=Qt.AlignmentFlag.AlignLeft) diff --git a/src/tagstudio/qt/views/layouts/flow_layout.py b/src/tagstudio/qt/views/layouts/flow_layout.py index 646998d4d2..9e52959dbf 100644 --- a/src/tagstudio/qt/views/layouts/flow_layout.py +++ b/src/tagstudio/qt/views/layouts/flow_layout.py @@ -3,8 +3,6 @@ # SPDX-FileCopyrightText: (C) TagStudio Contributors # SPDX-License-Identifier: GPL-3.0-only -# pyright: reportOptionalMemberAccess=false - """PySide6 port of the widgets/layouts/flowlayout example from Qt v6.x.""" @@ -13,13 +11,7 @@ from PySide6.QtCore import QMargins, QPoint, QRect, QSize, Qt from PySide6.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QWidget -IGNORE_SIZE = "ignore_size" - - -class FlowWidget(QWidget): - def __init__(self, parent: QWidget | None = None) -> None: - super().__init__(parent) - self.setProperty(IGNORE_SIZE, False) # noqa: FBT003 +from tagstudio.core.utils.types import unwrap class FlowLayout(QLayout): @@ -32,11 +24,6 @@ def __init__(self, parent: QWidget | None = None) -> None: self._item_list: list[QLayoutItem] = [] self.grid_efficiency = False - def __del__(self): - item = self.takeAt(0) - while item: - item = self.takeAt(0) - @override def addItem(self, arg__1: QLayoutItem) -> None: self._item_list.append(arg__1) @@ -69,8 +56,7 @@ def hasHeightForWidth(self) -> Literal[True]: @override def heightForWidth(self, arg__1: int) -> int: - height = self._do_layout(QRect(0, 0, arg__1, 0), test_only=True) - return int(height) + return self._do_layout(QRect(0, 0, arg__1, 0), test_only=True) @override def setGeometry(self, arg__1: QRect) -> None: @@ -101,7 +87,22 @@ def minimumSize(self) -> QSize: size += QSize(2 * self.contentsMargins().top(), 2 * self.contentsMargins().top()) return size - def _do_layout(self, rect: QRect, test_only: bool) -> float: + @staticmethod + def _style_spacing(widget: QWidget) -> tuple[int, int]: + style = widget.style() + spacing_x = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Horizontal, + ) + spacing_y = style.layoutSpacing( + QSizePolicy.ControlType.PushButton, + QSizePolicy.ControlType.PushButton, + Qt.Orientation.Vertical, + ) + return spacing_x, spacing_y + + def _do_layout(self, rect: QRect, test_only: bool) -> int: x = rect.x() y = rect.y() line_height = 0 @@ -110,55 +111,29 @@ def _do_layout(self, rect: QRect, test_only: bool) -> float: layout_spacing_y = 0 if self.grid_efficiency and self._item_list: - item = self._item_list[0] - style = item.widget().style() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Horizontal, - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Vertical, + layout_spacing_x, layout_spacing_y = self._style_spacing( + unwrap(self._item_list[0].widget()) ) for item in self._item_list: - skip_count = 0 - ignore_size: bool | None = item.widget().property(IGNORE_SIZE) - - if ignore_size: - skip_count += 1 - - else: - if not self.grid_efficiency: - style = item.widget().style() - layout_spacing_x = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Horizontal, - ) - layout_spacing_y = style.layoutSpacing( - QSizePolicy.ControlType.PushButton, - QSizePolicy.ControlType.PushButton, - Qt.Orientation.Vertical, - ) - space_x = spacing + layout_spacing_x - space_y = spacing + layout_spacing_y + if not self.grid_efficiency: + layout_spacing_x, layout_spacing_y = self._style_spacing(unwrap(item.widget())) + space_x = spacing + layout_spacing_x + space_y = spacing + layout_spacing_y + next_x = x + item.sizeHint().width() + space_x + if next_x - space_x > rect.right() and line_height > 0: + x = rect.x() + y = y + line_height + space_y next_x = x + item.sizeHint().width() + space_x - if next_x - space_x > rect.right() and line_height > 0: - x = rect.x() - y = y + line_height + space_y - next_x = x + item.sizeHint().width() + space_x - line_height = 0 + line_height = 0 - if not test_only: - item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) + if not test_only: + item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) - x = next_x - line_height = max(line_height, item.sizeHint().height()) + x = next_x + line_height = max(line_height, item.sizeHint().height()) - if len(self._item_list) == 0: + if not self._item_list: return 0 - return y + line_height - rect.y() * ((len(self._item_list)) / len(self._item_list)) + return y + line_height - rect.y() diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py index 3fd065b08e..076ea7e702 100644 --- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py +++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py @@ -3,371 +3,139 @@ import math -import time -from collections.abc import Iterable -from pathlib import Path -from typing import TYPE_CHECKING, Any, override +from collections.abc import Callable +from typing import override from PySide6.QtCore import QPoint, QRect, QSize, Signal -from PySide6.QtGui import QPixmap -from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea +from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea, QWidget -from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE -from tagstudio.core.library.alchemy.enums import ItemType -from tagstudio.core.library.alchemy.models import Entry from tagstudio.core.utils.types import unwrap -from tagstudio.previews.media_probe import can_have_duration, get_duration -from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb -from tagstudio.qt.qt_file_renderer import QtFileRenderer +from tagstudio.qt.views.styles.stylesheets import PAD -if TYPE_CHECKING: - from tagstudio.qt.qt_driver import QtDriver +class ThumbGridLayout[W: QWidget](QLayout): + """A recycling scrollable grid for widgets of the a uniform size.""" -class ThumbGridLayout(QLayout): - SPACING = 9 + _BUFFER_ROWS = 3 - _duration_loaded = Signal(Path, object) - visible_changed = Signal(int) # ID of first visible entry + first_visible_changed = Signal(int) # Index of the first visible item + items_assigned = Signal(object) # list[tuple[int, W]] of newly assigned (index, widget) pairs - def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: int = 0) -> None: + def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) -> None: super().__init__(None) self.setContentsMargins(0, 0, 0, 0) - self.setSpacing(self.SPACING) - self.driver: QtDriver = driver - self.scroll_area: QScrollArea = scroll_area - self._bottom_padding: int = bottom_padding + self.setSpacing(PAD) - self._item_thumbs: list[ItemThumb] = [] + self._assigned: dict[int, W] = {} + self._create_widget = create_widget + self._first_visible: int | None = None + self._free: list[W] = [] + self._item_count = 0 self._items: list[QLayoutItem] = [] - - self._entry_ids: list[int] = [] - self._entries: dict[int, Entry] = {} - # Tag.id -> {Entry.id} - self._tag_entries: dict[int, set[int]] = {} - self._entry_paths: dict[Path, int] = {} - # Entry.id -> _items[index] - self._entry_items: dict[int, int] = {} - - self._render_results: dict[Path, Any] = {} - self._renderer: QtFileRenderer = QtFileRenderer(self.driver.lib, self.driver.settings) - self._renderer.updated.connect(self._on_rendered) - self._render_cutoff: float = 0.0 - - self._durations: dict[Path, float | None] = {} - self._pending_durations: set[Path] = set() - self._duration_loaded.connect(self._on_duration_loaded) - - # _entry_ids[StartIndex:EndIndex], per_row - self._last_page_update: tuple[int, int, int] | None = None - + # [start, end, per_row, item size] + self._last_layout: tuple[int, int, int, QSize] | None = None + self._scroll_area = scroll_area self._scroll_to: int | None = None - def scroll_to(self, entry_id: int): - self._scroll_to = entry_id - - def set_entries(self, entry_ids: list[int]): - self._entry_ids = entry_ids - self._entries.clear() - self._tag_entries.clear() - self._entry_paths.clear() - - self._entry_items.clear() - self._render_results.clear() - self.driver.thumb_job_queue.queue.clear() - self._pending_durations.clear() - self._render_cutoff = time.time() - - base_size: tuple[int, int] = ( - self.driver.main_window.thumb_size, - self.driver.main_window.thumb_size, - ) - self.driver.thumb_job_queue.put( - ( - self._renderer.render, - ( - self.driver.cache_manager, - self._render_cutoff, - Path(), - base_size, - self.driver.main_window.devicePixelRatio(), - True, - True, - ), - ) - ) - - self._last_page_update = None - - def update_selected(self): - for item_thumb in self._item_thumbs: - value = item_thumb.item_id in self.driver._selected - item_thumb.thumb_button.set_selected(value) - - def add_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]): - for tag_id in tag_ids: - self._tag_entries.setdefault(tag_id, set()).update(entry_ids) - - def remove_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]): - for tag_id in tag_ids: - self._tag_entries.setdefault(tag_id, set()).difference_update(entry_ids) - - def _fetch_entries(self, ids: Iterable[int]): - ids = [id for id in ids if id not in self._entries] - entries = self.driver.lib.get_entries(ids) - for entry in entries: - self._entry_paths[unwrap(self.driver.lib.library_dir) / entry.path] = entry.id - self._entries[entry.id] = entry - - tag_ids = [TAG_ARCHIVED, TAG_FAVORITE] - tag_entries = self.driver.lib.get_tag_entries(tag_ids, ids) - for tag_id, entries in tag_entries.items(): - self._tag_entries.setdefault(tag_id, set()).update(entries) - - def _on_rendered(self, timestamp: float, image: QPixmap, size: QSize, file_path: Path): - if timestamp < self._render_cutoff: - return - self._render_results[file_path] = (timestamp, image, size, file_path) - - # If this is the loading image update all item_thumbs with pending thumbnails - if file_path == Path(): - for path, entry_id in self._entry_paths.items(): - if self._render_results.get(path, None) is None: - self._update_thumb(entry_id, image, size, file_path) - return - - if file_path not in self._entry_paths: - return - entry_id = self._entry_paths[file_path] - self._update_thumb(entry_id, image, size, file_path) - - def _update_thumb(self, entry_id: int, image: QPixmap, size: QSize, file_path: Path): - index = self._entry_items.get(entry_id) - if index is None: - return - item_thumb = self._item_thumbs[index] - item_thumb.update_thumb(image, file_path) - item_thumb.update_size(size) - item_thumb.set_filename_text(file_path) - item_thumb.set_extension(file_path) - item_thumb.set_duration(self._durations.get(file_path)) - - def _queue_duration(self, file_path: Path) -> None: - if ( - not can_have_duration(file_path) - or file_path in self._durations - or file_path in self._pending_durations - ): - return - self._pending_durations.add(file_path) - self.driver.thumb_job_queue.put((self._load_duration, (file_path,))) - - def _load_duration(self, file_path: Path) -> None: - self._duration_loaded.emit(file_path, get_duration(file_path)) - - def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None: - self._pending_durations.discard(file_path) - self._durations[file_path] = duration - - entry_id = self._entry_paths.get(file_path) - index = self._entry_items.get(entry_id) if entry_id is not None else None - if index is None: - return - item_thumb = self._item_thumbs[index] - if item_thumb.rendered_path == file_path: - item_thumb.set_duration(duration) - - def _item_thumb(self, index: int) -> ItemThumb: - if w := getattr(self.driver, "main_window", None): - base_size = (w.thumb_size, w.thumb_size) - else: - base_size = (128, 128) - while index >= len(self._item_thumbs): - show_filename = self.driver.settings.show_filenames_in_grid - item = ItemThumb( - ItemType.ENTRY, - self.driver.lib, - self.driver, - base_size, - show_filename_label=show_filename, - ) - self._item_thumbs.append(item) - self.addWidget(item) - return self._item_thumbs[index] - - def _size(self, width: int) -> tuple[int, int, int]: - if len(self._entry_ids) == 0: - return 0, 0, 0 - spacing = self.spacing() - - _item_thumb = self._item_thumb(0) - item = self._items[0] - item_size = item.sizeHint() - item_width = item_size.width() - item_height = item_size.height() - - width_offset = item_width + spacing - height_offset = item_height + spacing - - if width_offset == 0: - return 0, 0, height_offset - per_row = int(width / width_offset) - - return per_row, width_offset, height_offset - - @override - def heightForWidth(self, arg__1: int) -> int: - width = arg__1 - per_row, _, height_offset = self._size(width) - if per_row == 0: - return height_offset + self._bottom_padding - return math.ceil(len(self._entry_ids) / per_row) * height_offset + self._bottom_padding + def set_item_count(self, count: int) -> None: + """Replace all items with `count` new ones, unassigning every widget.""" + self._item_count = count + self._free.extend(self._assigned.values()) + self._assigned.clear() + self._first_visible = None + self._last_layout = None + self.update() + + def scroll_to_index(self, index: int) -> None: + self._scroll_to = index + self.update() + + def first_visible_index(self) -> int | None: + return self._first_visible + + def widget_at(self, index: int) -> W | None: + return self._assigned.get(index) + + def assigned_items(self) -> list[tuple[int, W]]: + return sorted(self._assigned.items(), key=lambda item: item[0]) + + def widgets(self) -> list[W]: + return [*self._assigned.values(), *self._free] + + def _new_widget(self) -> W: + widget = self._create_widget() + self.addWidget(widget) + return widget + + def _item_size(self) -> QSize: + """The first widget's size hint within its size limits, even before it's been shown.""" + if not self._items: + self._free.append(self._new_widget()) + widget = unwrap(self._items[0].widget()) + return widget.sizeHint().expandedTo(widget.minimumSize()).boundedTo(widget.maximumSize()) + + def _grid_metrics(self, width: int) -> tuple[int, QSize]: + """Return how many items fit in a row, and the size of each item's cell.""" + size = self._item_size() + cell = QSize(size.width() + self.spacing(), size.height() + self.spacing()) + return max(1, int(width / cell.width())), cell @override def setGeometry(self, arg__1: QRect) -> None: super().setGeometry(arg__1) - rect = arg__1 - if len(self._entry_ids) == 0: - for item in self._item_thumbs: - item.setGeometry(32_000, 32_000, 0, 0) + if self._item_count == 0: + self._free.extend(self._assigned.values()) + self._assigned.clear() + for widget in self._free: + widget.setGeometry(32_000, 32_000, 0, 0) return - per_row, width_offset, height_offset = self._size(rect.right()) - view_height = self.parentWidget().parentWidget().height() # pyright: ignore[reportOptionalMemberAccess] - offset = self.scroll_area.verticalScrollBar().value() + per_row, cell = self._grid_metrics(arg__1.width()) + scroll_bar = self._scroll_area.verticalScrollBar() if self._scroll_to is not None: - try: - index = self._entry_ids.index(self._scroll_to) - value = (index // per_row) * height_offset - self.scroll_area.verticalScrollBar().setMaximum(value) - self.scroll_area.verticalScrollBar().setSliderPosition(value) - offset = value - except ValueError: - pass + viewport_height = self._scroll_area.viewport().height() + maximum = max(0, self.totalHeightForWidth(arg__1.width()) - viewport_height) + scroll_bar.setMaximum(maximum) + scroll_bar.setValue(min((self._scroll_to // per_row) * cell.height(), maximum)) self._scroll_to = None - row_offset = offset - visible_rows = math.ceil((view_height + (row_offset % height_offset)) / height_offset) - row_offset = int(row_offset / height_offset) - start = row_offset * per_row - end = start + (visible_rows * per_row) - - first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None - self.visible_changed.emit(first_visible) + offset = scroll_bar.value() + last_row = (self._item_count - 1) // per_row + first_row = min(offset // cell.height(), last_row) + visible_rows = math.ceil( + (self._scroll_area.viewport().height() + offset % cell.height()) / cell.height() + ) - # Load closest off screen rows - start -= per_row * 3 - end += per_row * 3 + first_visible = first_row * per_row + if first_visible != self._first_visible: + self._first_visible = first_visible + self.first_visible_changed.emit(first_visible) - start = max(0, start) - end = min(len(self._entry_ids), end) - if (start, end, per_row) == self._last_page_update: + start = max(0, (first_row - self._BUFFER_ROWS) * per_row) + end = min(self._item_count, (first_row + visible_rows + self._BUFFER_ROWS) * per_row) + item_size = self._item_size() + if (start, end, per_row, item_size) == self._last_layout: return - self._last_page_update = (start, end, per_row) - - # Clear render queue if len > 2 pages - if len(self.driver.thumb_job_queue.queue) > (per_row * visible_rows * 2): - self.driver.thumb_job_queue.queue.clear() - self._pending_durations.clear() - pending = [] - for k, v in self._render_results.items(): - if v is None and k != Path(): - pending.append(k) - for k in pending: - self._render_results.pop(k) - - # Reorder items so previously rendered rows will reuse same item_thumbs - # When scrolling down top row gets moved to end of list - _ = self._item_thumb(end - start - 1) - for item_index, i in enumerate(range(start, end)): - if i >= len(self._entry_ids): - continue - entry_id = self._entry_ids[i] - if entry_id not in self._entry_items: - continue - prev_item_index = self._entry_items[entry_id] - if item_index == prev_item_index: - break - diff = prev_item_index - item_index - self._items = self._items[diff:] + self._items[:diff] - self._item_thumbs = self._item_thumbs[diff:] + self._item_thumbs[:diff] - break - self._entry_items.clear() - - # Move unused item_thumbs off screen - count = end - start - for item in self._item_thumbs[count:]: - item.setGeometry(32_000, 32_000, 0, 0) - - ratio = self.driver.main_window.devicePixelRatio() - base_size: tuple[int, int] = ( - self.driver.main_window.thumb_size, - self.driver.main_window.thumb_size, - ) - timestamp = time.time() - for item_index, i in enumerate(range(start, end)): - entry_id = self._entry_ids[i] - if entry_id not in self._entries: - ids = self._entry_ids[start:end] - self._fetch_entries(ids) - - entry = self._entries[entry_id] - row = int(i / per_row) - self._entry_items[entry_id] = item_index - item_thumb = self._item_thumb(item_index) - item = self._items[item_index] - col = i % per_row - item_x = width_offset * col - item_y = height_offset * row - item_thumb.setGeometry(QRect(QPoint(item_x, item_y), item.sizeHint())) - file_path = unwrap(self.driver.lib.library_dir) / entry.path - item_thumb.set_item(entry) - - if result := self._render_results.get(file_path): - _t, im, s, p = result - if item_thumb.rendered_path == p: - continue - self._update_thumb(entry_id, im, s, p) - else: - if Path() in self._render_results: - _t, im, s, p = self._render_results[Path()] - self._update_thumb(entry_id, im, s, p) - - if file_path not in self._render_results: - self._render_results[file_path] = None - self.driver.thumb_job_queue.put( - ( - self._renderer.render, - ( - self.driver.cache_manager, - timestamp, - file_path, - base_size, - ratio, - False, - True, - entry.date_modified, - ), - ) - ) - - # Queued after thumbnails so they don't hold up rendering - for i in range(start, end): - entry = self._entries[self._entry_ids[i]] - self._queue_duration(unwrap(self.driver.lib.library_dir) / entry.path) - - # set_selected causes stutters making thumbs after selected not show for a frame - # setting it after positioning thumbs fixes this - for i in range(start, end): - if i >= len(self._entry_ids): - continue - entry_id = self._entry_ids[i] - item_index = self._entry_items[entry_id] - item_thumb = self._item_thumbs[item_index] - item_thumb.thumb_button.set_selected(entry_id in self.driver._selected) - - item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED]) - item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE]) + self._last_layout = (start, end, per_row, item_size) + + for index in [index for index in self._assigned if not start <= index < end]: + self._free.append(self._assigned.pop(index)) + + newly_assigned: list[tuple[int, W]] = [] + for index in range(start, end): + widget = self._assigned.get(index) + if widget is None: + widget = self._free.pop() if self._free else self._new_widget() + self._assigned[index] = widget + newly_assigned.append((index, widget)) + row, column = divmod(index, per_row) + widget.setGeometry(QRect(QPoint(column * cell.width(), row * cell.height()), item_size)) + + for widget in self._free: + widget.setGeometry(32_000, 32_000, 0, 0) + + if newly_assigned: + self.items_assigned.emit(newly_assigned) @override def addItem(self, arg__1: QLayoutItem) -> None: @@ -375,19 +143,37 @@ def addItem(self, arg__1: QLayoutItem) -> None: @override def count(self) -> int: - return len(self._entries) + return len(self._items) + + @override + def itemAt(self, index: int) -> QLayoutItem: + if not 0 <= index < len(self._items): + return None # pyright: ignore[reportReturnType] + return self._items[index] + + @override + def takeAt(self, index: int) -> QLayoutItem: + if not 0 <= index < len(self._items): + return None # pyright: ignore[reportReturnType] + item = self._items.pop(index) + widget = item.widget() + self._free = [w for w in self._free if w is not widget] + self._assigned = {i: w for i, w in self._assigned.items() if w is not widget} + return item @override def hasHeightForWidth(self) -> bool: return True @override - def itemAt(self, index: int) -> QLayoutItem: - if index >= len(self._items): - return None # pyright: ignore[reportReturnType] - return self._items[index] + def heightForWidth(self, arg__1: int) -> int: + if self._item_count == 0: + return 0 + per_row, cell = self._grid_metrics(arg__1) + return math.ceil(self._item_count / per_row) * cell.height() @override def sizeHint(self) -> QSize: - self._item_thumb(0) - return self._items[0].minimumSize() + if not self._items: + return QSize() + return self._item_size() diff --git a/src/tagstudio/qt/views/styles/palette.py b/src/tagstudio/qt/views/styles/palette.py index 51aeef0bcd..6527ba26d6 100644 --- a/src/tagstudio/qt/views/styles/palette.py +++ b/src/tagstudio/qt/views/styles/palette.py @@ -8,7 +8,8 @@ from typing import Any import structlog -from PySide6.QtGui import QColor, QPalette +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QGuiApplication, QPalette from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.utils.singleton import Singleton @@ -45,6 +46,15 @@ def accent() -> QColor: return QPalette.color(Palette._palette, role) + @staticmethod + def accent_alt() -> QColor: + """An alternate accent color variant that's lighter in dark mode.""" + return QColor( + Palette.accent().lighter(110) + if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark + else Palette.accent() + ) + class ColorType(IntEnum): PRIMARY = 0 diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py index fffad44bf5..74bbe3a716 100644 --- a/src/tagstudio/qt/views/styles/stylesheets.py +++ b/src/tagstudio/qt/views/styles/stylesheets.py @@ -3,6 +3,7 @@ import platform +from pathlib import Path from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QGuiApplication @@ -608,7 +609,7 @@ def _is_dark_theme() -> bool: def get_contrast_text_color(background_color: QColor) -> QColor: """Return plain black or white, whichever reads better against `background_color`.""" - return QColor(0, 0, 0) if background_color.lightness() > 120 else QColor(255, 255, 255) + return QColor(0, 0, 0) if background_color.lightness() > 140 else QColor(255, 255, 255) def _banner_button_hover_style(object_name: str) -> str: @@ -666,7 +667,7 @@ def banner_action_button_style() -> str: def banner_notice_bg_color() -> QColor: """Fill color for the banner card in "notice" mode.""" - color = QColor(Palette.accent()) + color = Palette.accent_alt() color.setAlpha(235) return color @@ -744,11 +745,68 @@ def pagination_style() -> str: """ +def item_thumb_label_style(is_large: bool = False) -> str: + """Style used for text labels on item thumbnails, like file extensions and durations.""" + font_size = 18 if is_large else 12 + return ( + "background-color:rgba(0, 0, 0, 192);" + "color:#FFFFFF;" + "font-family:Oxanium;" + "font-weight:bold;" + f"font-size:{font_size}px;" + "border-radius:3px;" + "padding-top: 4px;" + "padding-right: 1px;" + "padding-bottom: 1px;" + "padding-left: 1px;" + ) + + +def item_thumb_check_badge_style(size: int, icon: Path, checked_icon: Path) -> str: + """Style used for checkable tag badges on item thumbnails, like Favorite and Archived.""" + return ( + f"QCheckBox::indicator{{width: {size}px;height: {size}px;}}" + f'QCheckBox::indicator::unchecked{{image: url("{icon.as_posix()}")}}' + f'QCheckBox::indicator::checked{{image: url("{checked_icon.as_posix()}")}}' + ) + + +def item_thumb_filename_style(is_selected: bool = False) -> str: + """Style used for filename labels under item thumbnails, highlighted when selected.""" + if is_selected: + return ( + "background-color: palette(highlight);" + "color: palette(highlighted-text);" + "font-size: 10px;" + "padding-bottom: 1px;" + "padding-left: 1px;" + f"border-radius: {HALF_PAD}px;" + ) + return "font-size: 10px; padding-left: 1px; padding-bottom: 1px;" + + +def item_thumb_faded_select_color() -> QColor: + """Semi-transparent accent color used to fill selected item thumbnails.""" + color = Palette.accent() + color.setHsl(color.hslHue(), color.hslSaturation(), max(color.lightness(), 127), 127) + return color + + +def item_thumb_hover_color() -> QColor: + """Lightened accent color used to outline hovered item thumbnails.""" + color = Palette.accent() + color.setHsl( + color.hslHue(), + min(255, round(color.hslSaturation() * 1.25)), + min(color.lightness() + 80, 255), + color.alpha(), + ) + return color + + def banner_progress_chunk_color() -> QColor: """Fill color for the banner's custom-painted progress bar chunk.""" - is_dark = _is_dark_theme() - chunk = QColor(Palette.accent().lighter(130) if is_dark else Palette.accent().darker(115)) - chunk.setAlpha(235) + chunk = Palette.accent_alt() return chunk diff --git a/src/tagstudio/qt/views/thumb_button.py b/src/tagstudio/qt/views/thumb_button.py deleted file mode 100644 index b71bc3ead9..0000000000 --- a/src/tagstudio/qt/views/thumb_button.py +++ /dev/null @@ -1,96 +0,0 @@ -# SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only - - -from typing import override - -from PySide6 import QtCore -from PySide6.QtCore import QEvent -from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEvent, QPen -from PySide6.QtWidgets import QPushButton, QWidget - -from tagstudio.qt.views.styles.palette import Palette -from tagstudio.qt.views.styles.stylesheets import RADIUS - - -# TODO: Use newer MVC style guidelines -class ThumbButton(QPushButton): - def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None: - super().__init__(parent) - self.thumb_size: tuple[int, int] = thumb_size - self.hovered = False - self.selected = False - self.select_color = Palette.accent() - - self.select_color_faded = Palette.accent() - self.select_color_faded.setHsl( - self.select_color_faded.hslHue(), - self.select_color_faded.hslSaturation(), - max(self.select_color_faded.lightness(), 127), - 127, - ) - - self.hover_color = Palette.accent() - self.hover_color.setHsl( - self.hover_color.hslHue(), - self.hover_color.hslSaturation(), - min(self.hover_color.lightness() + 80, 255), - self.hover_color.alpha(), - ) - - @override - def paintEvent(self, arg__1: QPaintEvent) -> None: - super().paintEvent(arg__1) - if self.hovered or self.selected: - painter = QPainter() - painter.begin(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - path = QPainterPath() - width = 3 - path.addRoundedRect( - QtCore.QRectF( - width / 2, - width / 2, - self.thumb_size[0] - width, - self.thumb_size[1] - width, - ), - RADIUS, - RADIUS, - ) - - if self.selected: - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight) - pen = QPen(self.select_color_faded, width) - painter.setPen(pen) - painter.fillPath(path, self.select_color_faded) - painter.drawPath(path) - - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - color: QColor = self.select_color if not self.hovered else self.hover_color - pen = QPen(color, width) - painter.setPen(pen) - painter.drawPath(path) - elif self.hovered: - painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source) - pen = QPen(self.hover_color, width) - painter.setPen(pen) - painter.drawPath(path) - - painter.end() - - @override - def enterEvent(self, event: QEnterEvent) -> None: - self.hovered = True - self.repaint() - return super().enterEvent(event) - - @override - def leaveEvent(self, event: QEvent) -> None: - self.hovered = False - self.repaint() - return super().leaveEvent(event) - - def set_selected(self, value: bool) -> None: - if value != self.selected: - self.selected = value - self.repaint() diff --git a/tests/conftest.py b/tests/conftest.py index cd83063baf..5650cfeed7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,8 +27,9 @@ from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.alchemy.models import Entry, Tag from tagstudio.core.library.ignore import GLOBAL_IGNORE, Ignore, IgnoreMatcher +from tagstudio.qt.controllers.thumb_grid import ThumbGrid +from tagstudio.qt.entry_state import EntryState from tagstudio.qt.qt_driver import QtDriver -from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout @pytest.fixture @@ -196,23 +197,29 @@ class Args: open = library_dir ci = True + # In-memory libraries are per-thread, so grid entry fetches run on the test thread + grid_fetch_pool = Mock() + grid_fetch_pool.return_value.start.side_effect = lambda task: task() + with ( patch("tagstudio.qt.qt_driver.Consumer"), patch("tagstudio.qt.qt_driver.CustomRunnable"), # NOTE: Patching the QThreadPool stops Python's garbage collector from causing segfaults patch("tagstudio.qt.qt_driver.QThreadPool"), + patch("tagstudio.qt.controllers.thumb_grid.QThreadPool", grid_fetch_pool), ): driver = QtDriver(Args()) # pyright: ignore[reportArgumentType] + driver.lib = library + driver.entry_state = EntryState(library) driver.app = Mock() driver.main_window = Mock() driver.main_window.thumb_size = 128 - driver.main_window.thumb_layout = ThumbGridLayout(driver, QScrollArea()) + driver.main_window.thumb_grid = ThumbGrid(driver, QScrollArea()) driver.main_window.menu_bar.autofill_action = Mock() driver.copy_buffer = {"fields": [], "tags": []} - driver.lib = library # TODO - downsize this method and use it # driver.start() driver.frame_content = [e.id for e in library.all_entries()] diff --git a/tests/core/macros/test_folders_tags.py b/tests/core/macros/test_folders_tags.py index c3fd32becb..b8ae7d1ce5 100644 --- a/tests/core/macros/test_folders_tags.py +++ b/tests/core/macros/test_folders_tags.py @@ -3,10 +3,11 @@ from tagstudio.core.library.alchemy.library import Library +from tagstudio.qt.entry_state import EntryState from tagstudio.qt.mixed.folders_to_tags import folders_to_tags def test_folders_to_tags(library: Library): - folders_to_tags(library) + folders_to_tags(library, EntryState(library)) entry = [x for x in library.all_entries(with_joins=True) if "bar.md" in str(x.path)][0] assert {x.name for x in entry.tags} == {"two", "bar"} diff --git a/tests/qt/test_entry_state.py b/tests/qt/test_entry_state.py new file mode 100644 index 0000000000..82cec48f98 --- /dev/null +++ b/tests/qt/test_entry_state.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + +# pyright: reportPrivateUsage=false + + +from PySide6.QtCore import QRect + +from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE +from tagstudio.core.library.alchemy.library import Library +from tagstudio.core.utils.types import unwrap +from tagstudio.qt.entry_state import EntryState +from tagstudio.qt.qt_driver import QtDriver + + +def test_badge_tags_update_state_and_emit(library: Library): + entry_state = EntryState(library) + changed: list[set[int]] = [] + entry_state.tags_changed.connect(changed.append) + + entry_state.add_tags([1, 2], TAG_FAVORITE) + assert entry_state.has_badge(1, TAG_FAVORITE) + assert entry_state.has_badge(2, TAG_FAVORITE) + assert library.get_tag_entries([TAG_FAVORITE], [1, 2])[TAG_FAVORITE] == {1, 2} + assert changed == [{1, 2}] + + entry_state.remove_tags(2, TAG_FAVORITE) + assert entry_state.has_badge(1, TAG_FAVORITE) + assert not entry_state.has_badge(2, TAG_FAVORITE) + assert changed[-1] == {2} + + +def test_non_badge_tags_emit(library: Library): + entry_state = EntryState(library) + changed: list[set[int]] = [] + entry_state.tags_changed.connect(changed.append) + + non_badge_tag = 2000 + entry_state.add_tags(1, non_badge_tag) + assert changed == [{1}] + + +def test_unload_badges_rereads_from_library(library: Library): + entry_state = EntryState(library) + library.add_tags_to_entries(1, TAG_ARCHIVED) + entry_state.unload_badges() + assert entry_state.has_badge(1, TAG_ARCHIVED) + + +def test_badge_click_updates_whole_selection(qt_driver: QtDriver): + grid = qt_driver.main_window.thumb_grid + grid.set_entries([1, 2]) + grid.layout().setGeometry(QRect(0, 0, 1000, 1000)) + thumbs = [unwrap(grid.layout().widget_at(index)) for index in (0, 1)] + + qt_driver.toggle_item_selection(1, append=False, bridge=False) + qt_driver.toggle_item_selection(2, append=True, bridge=False) + thumbs[0].layout().badges[TAG_FAVORITE].setChecked(True) + + assert all(thumb.layout().badges[TAG_FAVORITE].isChecked() for thumb in thumbs) + assert qt_driver.lib.get_tag_entries([TAG_FAVORITE], [1, 2])[TAG_FAVORITE] == {1, 2} diff --git a/tests/qt/test_flow_widget.py b/tests/qt/test_flow_layout.py similarity index 100% rename from tests/qt/test_flow_widget.py rename to tests/qt/test_flow_layout.py diff --git a/tests/qt/test_inspector.py b/tests/qt/test_inspector.py index f5c42fa6d6..27356dc363 100644 --- a/tests/qt/test_inspector.py +++ b/tests/qt/test_inspector.py @@ -3,8 +3,13 @@ # pyright: reportPrivateUsage=false +from unittest.mock import patch + +from tagstudio.core.constants import TAG_FAVORITE from tagstudio.core.library.alchemy.models import Entry +from tagstudio.core.utils.types import unwrap from tagstudio.qt.controllers.inspector import Inspector +from tagstudio.qt.controllers.tiles.tag_data import TagData from tagstudio.qt.qt_driver import QtDriver @@ -47,3 +52,31 @@ def test_update_selection_multiple(qt_driver: QtDriver): # Panel should enable UI that allows for entry modification assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled() assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled() + + +def test_tag_changes_refresh_once(qt_driver: QtDriver, entry_full: Entry): + panel = Inspector(qt_driver) + qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False) + panel.set_selection(qt_driver.selected) + containers = panel.containers + + def shown_tag_ids() -> set[int]: + return {tag.id for tag in containers.cached_entries[0].tags} + + with patch.object( + containers, "update_from_entry", wraps=containers.update_from_entry + ) as refresh: + panel.layout().tag_search_box.item_chosen.emit(2000) + assert refresh.call_count == 1 + assert shown_tag_ids() == {1000, 2000} + assert set(panel.layout().tag_search_box.added) == {1000, 2000} + + tag_data = containers._tiles[0].get_inner_widget() + assert isinstance(tag_data, TagData) + tag_data._on_remove(unwrap(qt_driver.lib.get_tag(2000))) + assert refresh.call_count == 2 + assert shown_tag_ids() == {1000} + + qt_driver.entry_state.add_tags(entry_full.id, TAG_FAVORITE) + assert refresh.call_count == 3 + assert shown_tag_ids() == {1000, TAG_FAVORITE} diff --git a/tests/qt/test_item_thumb.py b/tests/qt/test_item_thumb.py index 0506c97b30..876e98ba33 100644 --- a/tests/qt/test_item_thumb.py +++ b/tests/qt/test_item_thumb.py @@ -4,8 +4,9 @@ import pytest +from tagstudio.core.constants import TAG_FAVORITE from tagstudio.core.library.alchemy.enums import ItemType -from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb +from tagstudio.qt.controllers.item_thumb import ItemThumb from tagstudio.qt.qt_driver import QtDriver @@ -18,8 +19,5 @@ def test_badge_visual_state(qt_driver: QtDriver, entry_min: int, new_value: bool qt_driver.frame_content = [entry_min] qt_driver.toggle_item_selection(0, append=False, bridge=False) - thumb.badges[BadgeType.FAVORITE].setChecked(new_value) - assert thumb.badges[BadgeType.FAVORITE].isChecked() == new_value - # TODO - # assert thumb.favorite_badge.isHidden() == initial_state - assert thumb.is_favorite == new_value + thumb.layout().badges[TAG_FAVORITE].setChecked(new_value) + assert thumb.layout().badges[TAG_FAVORITE].isChecked() == new_value diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py index 2317bd30fb..2d331233d2 100644 --- a/tests/qt/test_qt_driver.py +++ b/tests/qt/test_qt_driver.py @@ -4,7 +4,7 @@ from tagstudio.core.library.alchemy.enums import BrowsingState from tagstudio.core.utils.types import unwrap -from tagstudio.qt.qt_driver import QtDriver +from tagstudio.qt.qt_driver import History, QtDriver def test_browsing_state_update(qt_driver: QtDriver): @@ -12,7 +12,7 @@ def test_browsing_state_update(qt_driver: QtDriver): entries = qt_driver.lib.all_entries(with_joins=True) ids = [e.id for e in entries] qt_driver.frame_content = ids - qt_driver.main_window.thumb_layout.set_entries(ids) + qt_driver.main_window.thumb_grid.set_entries(ids) # no filter, both items are returned qt_driver.update_browsing_state() @@ -47,8 +47,26 @@ def test_close_library(qt_driver: QtDriver): assert qt_driver.lib.library_dir is None assert not qt_driver.frame_content assert not qt_driver.selected - assert len(qt_driver.main_window.thumb_layout._entry_ids) == 0 + assert len(qt_driver.main_window.thumb_grid._entry_ids) == 0 # close library again to see there's no error qt_driver.close_library() qt_driver.close_library(is_shutdown=True) + + +def test_new_results_start_on_first_page(): + state = BrowsingState(page_index=3, first_visible_entry_id=42) + new_state = state.with_search_query("tag_id:1000") + assert new_state.page_index == 0 + assert new_state.first_visible_entry_id is None + + +def test_history_skips_repeated_states(): + history = History(BrowsingState.from_search_query("foo")) + history.push(BrowsingState(query="foo", first_visible_entry_id=1)) + history.push(BrowsingState.from_search_query("bar")) + + assert not history.move(1) + assert history.move(-1) + assert history.current.query == "foo" + assert not history.move(-1)