diff --git a/data/ui/figure_settings.blp b/data/ui/figure_settings.blp index 15ba194cc..f1fe9b06c 100644 --- a/data/ui/figure_settings.blp +++ b/data/ui/figure_settings.blp @@ -179,19 +179,16 @@ template $GraphsFigureSettingsWindow : Adw.Window { } } - Adw.ActionRow style_row { + Adw.ActionRow { title: _("Style"); - activatable-widget: styles; hexpand: true; - - Button styles { - tooltip-text: _("Choose Style"); - valign: center; - clicked => $choose_style(); - styles ["flat"] - Image { - icon-name: "right-symbolic"; - } + activatable: true; + activated => $choose_style(); + [suffix] + Label style_name {} + [suffix] + Image { + icon-name: "go-next-symbolic"; } } } diff --git a/src/export_figure.py b/src/export_figure.py index dd7456b9e..1181b1951 100644 --- a/src/export_figure.py +++ b/src/export_figure.py @@ -1,6 +1,5 @@ # SPDX-License-Identifier: GPL-3.0-or-later import contextlib -import io from gettext import gettext as _ from pathlib import Path @@ -40,19 +39,15 @@ def on_accept(self, _button): def on_response(dialog, response): with contextlib.suppress(GLib.GError): file = dialog.save_finish(response) - buffer = io.BytesIO() - self._canvas.figure.savefig( - buffer, format=file_suffixes[0], - dpi=int(self.dpi.get_value()), - transparent=self.transparent.get_active(), - ) - stream = file_io.get_write_stream(file) - stream.write(buffer.getvalue()) - buffer.close() - stream.close() - self.get_application().get_window().add_toast_string( - _("Exported Figure")) - self.destroy() + with file_io.open_wrapped(file, "wb") as wrapper: + self._canvas.figure.savefig( + wrapper, format=file_suffixes[0], + dpi=int(self.dpi.get_value()), + transparent=self.transparent.get_active(), + ) + self.get_application().get_window().add_toast_string( + _("Exported Figure")) + self.destroy() dialog = Gtk.FileDialog() dialog.set_initial_name(f"{filename}.{file_suffixes[0]}") diff --git a/src/figure_settings.py b/src/figure_settings.py index cf0ace887..ad2038f13 100644 --- a/src/figure_settings.py +++ b/src/figure_settings.py @@ -20,7 +20,7 @@ def _on_bind(_factory, item, window): widget = item.get_child() style = item.get_item() widget.style = style - if style.mutable: + if style.get_mutable(): widget.edit_button.set_visible(True) widget.edit_button.connect("clicked", window.edit_style, style) @@ -58,25 +58,30 @@ class FigureSettingsWindow(Adw.Window): navigation_view = Gtk.Template.Child() grid_view = Gtk.Template.Child() toast_overlay = Gtk.Template.Child() + style_name = Gtk.Template.Child() figure_settings = GObject.Property(type=Graphs.FigureSettings) def __init__(self, application, highlighted=None): + figure_settings = application.get_data().get_figure_settings() super().__init__( application=application, transient_for=application.get_window(), - figure_settings=application.get_data().get_figure_settings(), + figure_settings=figure_settings, ) - ignorelist = [ - "custom_style", "min_selected", "max_selected", "use_custom_style", - ] + notifiers = ("custom_style", "use_custom_style") + for prop in notifiers: + figure_settings.connect( + "notify::" + prop.replace("_", "-"), + getattr(self, "_on_" + prop), + ) + + ignorelist = list(notifiers) + ["min_selected", "max_selected"] for direction in _DIRECTIONS: ignorelist.append(f"min_{direction}") ignorelist.append(f"max_{direction}") - ui.bind_values_to_object( - self.props.figure_settings, self, ignorelist=ignorelist, - ) + ui.bind_values_to_object(figure_settings, self, ignorelist=ignorelist) self.set_axes_entries() self.no_data_message.set_visible( self.get_application().get_data().is_empty(), @@ -86,31 +91,43 @@ def __init__(self, application, highlighted=None): self.style_editor = styles.StyleEditor(self) self.grid_view.set_factory(_get_widget_factory(self)) - selection_model = self.grid_view.get_model() - selection_model.set_model( + self.grid_view.get_model().set_model( application.get_figure_style_manager().get_style_model(), ) - if self.props.figure_settings.get_use_custom_style(): - stylename = self.props.figure_settings.get_custom_style() + self._on_use_custom_style(figure_settings, None) + self.present() + + def _on_use_custom_style(self, figure_settings, _a) -> None: + if figure_settings.get_use_custom_style(): + self._on_custom_style(figure_settings, None) + else: + self.style_name.set_text(_("System")) + self.grid_view.get_model().set_selected(0) + + def _on_custom_style(self, figure_settings, _a) -> None: + if figure_settings.get_use_custom_style(): + selection_model = self.grid_view.get_model() + stylename = figure_settings.get_custom_style() + self.style_name.set_text(stylename) for index, style in enumerate(selection_model): - if index > 0 and style.name == stylename: + if index > 0 and style.get_name() == stylename: selection_model.set_selected(index) break - else: - selection_model.set_selected(0) - self.present() @Gtk.Template.Callback() def on_select(self, model, _pos, _n_items): figure_settings = self.props.figure_settings selected_item = model.get_selected_item() # Don't trigger unneccesary reloads - if selected_item.file is None: # System style + if selected_item.get_file() is None: # System style if figure_settings.get_use_custom_style(): figure_settings.set_use_custom_style(False) + self.style_name.set_text(_("System")) else: - if selected_item.name != figure_settings.get_custom_style(): - figure_settings.set_custom_style(selected_item.name) + stylename = selected_item.get_name() + if stylename != figure_settings.get_custom_style(): + figure_settings.set_custom_style(stylename) + self.style_name.set_text(stylename) if not figure_settings.get_use_custom_style(): figure_settings.set_use_custom_style(True) @@ -141,7 +158,7 @@ def edit_style(self, _button, style): figure_settings = \ self.props.application.get_data().get_figure_settings() if figure_settings.get_use_custom_style() \ - and figure_settings.get_custom_style() == style.name: + and figure_settings.get_custom_style() == style.get_name(): self.grid_view.get_model().set_selected(0) self.style_editor.load_style(style) self.navigation_view.push(self.style_editor) diff --git a/src/file_io.py b/src/file_io.py index 91a2f7a5c..a7760b229 100644 --- a/src/file_io.py +++ b/src/file_io.py @@ -1,48 +1,125 @@ # SPDX-License-Identifier: GPL-3.0-or-later +import io import json from xml.dom import minidom -from gi.repository import GLib +from gi.repository import GLib, Gio + + +class FileLikeWrapper(io.BufferedIOBase): + def __init__(self, read_stream=None, write_stream=None): + self._read_stream, self._write_stream = read_stream, write_stream + + @classmethod + def new_for_io_stream(cls, io_stream: Gio.IOStream): + return cls( + read_stream=io_stream.get_input_stream(), + write_stream=io_stream.get_output_stream(), + ) + + @property + def closed(self) -> bool: + return self._read_stream is None and self._write_stream is None + + def close(self) -> None: + if self._read_stream is not None: + self._read_stream.close() + self._read_stream = None + if self._write_stream is not None: + self._write_stream.close() + self._write_stream = None + + def writable(self) -> bool: + return self._write_stream is not None + + def write(self, b) -> int: + if self._write_stream is None: + raise OSError() + elif b is None or b == b"": + return 0 + return self._write_stream.write_bytes(GLib.Bytes(b)) + + def readable(self) -> bool: + return self._read_stream is not None + + def read(self, size=-1): + if self._read_stream is None: + raise OSError() + elif size == 0: + return b"" + elif size > 0: + return self._read_stream.read_bytes(size, None).get_data() + buffer = io.BytesIO() + while True: + chunk = self._read_stream.read_bytes(4096, None) + if chunk.get_size() == 0: + break + buffer.write(chunk.get_data()) + return buffer.getvalue() + + read1 = read + + +def open_wrapped(file: Gio.File, mode: str = "rt", encoding: str = "utf-8"): + read = "r" in mode + append = "a" in mode + replace = "w" in mode + + def _create_stream(): + if file.query_exists(None): + file.delete(None) + return file.create(0, None) + + def _io_stream(): + return FileLikeWrapper.new_for_io_stream(file.open_readwrite(None)) + + if "x" in mode: + if file.query_exists(): + return OSError() + stream = _create_stream() + stream.close() + if read and append: + obj = _io_stream() + elif read and replace: + stream = _create_stream() + stream.close() + obj = _io_stream() + elif read: + obj = FileLikeWrapper(read_stream=file.read(None)) + elif replace: + obj = FileLikeWrapper(write_stream=_create_stream()) + elif append: + obj = FileLikeWrapper(write_stream=file.append(None)) + + if "b" not in mode: + obj = io.TextIOWrapper(obj, encoding=encoding) + return obj def save_item(file, item_): delimiter = "\t" fmt = delimiter.join(["%.12e"] * 2) - stream = get_write_stream(file) xlabel, ylabel = item_.get_xlabel(), item_.get_ylabel() - if xlabel != "" and ylabel != "": - write_string(stream, xlabel + delimiter + ylabel + "\n") - for values in zip(item_.xdata, item_.ydata): - write_string(stream, fmt % values + "\n") - stream.close() + with open_wrapped(file, "wt") as wrapper: + if xlabel != "" and ylabel != "": + wrapper.write(xlabel + delimiter + ylabel + "\n") + for values in zip(item_.xdata, item_.ydata): + wrapper.write(fmt % values + "\n") def parse_json(file): - return json.loads(file.load_bytes(None)[0].get_data()) + with open_wrapped(file, "rb") as wrapper: + return json.load(wrapper) def write_json(file, json_object, pretty_print=True): - stream = get_write_stream(file) - write_string(stream, json.dumps( - json_object, indent=4 if pretty_print else None, sort_keys=True, - )) - stream.close() + with open_wrapped(file, "wt") as wrapper: + json.dump( + json_object, wrapper, + indent=4 if pretty_print else None, sort_keys=True, + ) def parse_xml(file): - return minidom.parseString(read_file(file)) - - -def get_write_stream(file): - if file.query_exists(None): - file.delete(None) - return file.create(0, None) - - -def write_string(stream, line, encoding="utf-8"): - stream.write_bytes(GLib.Bytes(line.encode(encoding)), None) - - -def read_file(file, encoding="utf-8"): - content = file.load_bytes(None)[0].get_data() - return content if encoding is None else content.decode(encoding) + with open_wrapped(file, "rb") as wrapper: + return minidom.parse(wrapper) diff --git a/src/item.py b/src/item.py index 7e06cfaa7..93e234a13 100644 --- a/src/item.py +++ b/src/item.py @@ -34,9 +34,9 @@ class DataItem(Graphs.Item): markerstyle = GObject.Property(type=int, default=0) markersize = GObject.Property(type=float, default=7) - @staticmethod - def new(params, xdata=None, ydata=None, **kwargs): - return DataItem( + @classmethod + def new(cls, params, xdata=None, ydata=None, **kwargs): + return cls( linestyle=misc.LINESTYLES.index(params["lines.linestyle"]), linewidth=params["lines.linewidth"], markerstyle=misc.MARKERSTYLES.index(params["lines.marker"]), @@ -68,9 +68,9 @@ class TextItem(Graphs.Item): size = GObject.Property(type=float, default=12) rotation = GObject.Property(type=int, default=0, minimum=0, maximum=360) - @staticmethod - def new(params, xanchor=0, yanchor=0, text="", **kwargs): - return TextItem( + @classmethod + def new(cls, params, xanchor=0, yanchor=0, text="", **kwargs): + return cls( size=params["font.size"], color=params["text.color"], xanchor=xanchor, yanchor=yanchor, text=text, **kwargs, ) @@ -85,9 +85,9 @@ class FillItem(Graphs.Item): data = GObject.Property(type=object) - @staticmethod - def new(_params, data, **kwargs): - return FillItem(data=data, **kwargs) + @classmethod + def new(cls, _params, data, **kwargs): + return cls(data=data, **kwargs) def __init__(self, **kwargs): super().__init__(**kwargs) diff --git a/src/migrate.py b/src/migrate.py index 4f621750c..d9940ecf0 100644 --- a/src/migrate.py +++ b/src/migrate.py @@ -58,17 +58,11 @@ def migrate_config(settings): def _migrate_config(settings_, config_file): config = file_io.parse_json(config_file) for old_key, (category, key) in CONFIG_MIGRATION_TABLE.items(): - with contextlib.suppress(KeyError): - settings = settings_.get_child(category) + with contextlib.suppress(KeyError, ValueError): value = config[old_key] if "scale" in key: value = value.capitalize() - if isinstance(value, str): - settings.set_string(key, value) - elif isinstance(value, bool): - settings.set_boolean(key, value) - elif isinstance(value, int): - settings.set_int(key, value) + settings_.get_child(category)[key] = value config_file.delete(None) @@ -78,10 +72,10 @@ def _migrate_import_params(settings_, import_file): for key, value in params.items(): if key == "separator": settings.set_string(key, f"{value} ") - elif isinstance(value, str): - settings.set_string(key, value) + continue elif isinstance(value, int): - settings.set_int(key.replace("_", "-"), value) + key = key.replace("_", "-") + settings[key] = value import_file.delete(None) @@ -103,18 +97,17 @@ def _migrate_styles(old_styles_dir, new_config_dir): enumerator = old_styles_dir.enumerate_children("default::*", 0, None) adwaita = style_io.parse(Gio.File.new_for_uri( "resource:///se/sjoerd/Graphs/styles/adwaita.mplstyle", - )) + ))[0] for file in map(enumerator.get_child, enumerator): stylename = Path(utilities.get_filename(file)).stem if stylename not in SYSTEM_STYLES: - params = style_io.parse(file) + params = style_io.parse(file)[0] for key, value in adwaita.items(): if key not in params: params[key] = value - params.name = stylename style_io.write(new_styles_dir.get_child_for_display_name( f"{stylename.lower().replace(' ', '-')}.mplstyle", - ), params) + ), stylename, params) file.delete(None) enumerator.close(None) old_styles_dir.delete(None) @@ -168,7 +161,7 @@ def migrate(self) -> dict: class ItemBase: def migrate(self) -> dict: - dictionary = {"item_type": self.item_type} + dictionary = {"type": self.item_type} for key, value in self.__dict__.items(): with contextlib.suppress(KeyError): key = ITEM_MIGRATION_TABLE[key] @@ -195,7 +188,8 @@ class TextItem(ItemBase): def migrate_project(file): sys.modules["graphs.misc"] = sys.modules[__name__] sys.modules["graphs.item"] = sys.modules[__name__] - project = pickle.loads(file_io.read_file(file, None)) + with file_io.open_wrapped(file, "rb") as wrapper: + project = pickle.load(wrapper) figure_settings = project["plot_settings"].migrate() current_limits = [figure_settings[key] for key in misc.LIMITS] @@ -222,7 +216,7 @@ def _migrate_clipboard(clipboard, clipboard_pos, current_limits): if len(clipboard) > 100: clipboard = clipboard[len(clipboard) - 100:] states = [ - {item.uuid: item.migrate() for item in state.values()} + {item.key: item.migrate() for item in state.values()} for state in clipboard ] new_clipboard.append(([], DEFAULT_VIEW.copy())) @@ -265,7 +259,7 @@ def _migrate_clipboard(clipboard, clipboard_pos, current_limits): def _get_limits(items): limits = [None] * 8 for item in items: - if item["item_type"] != "Item": + if item["type"] != "Item": continue for count, x_or_y in enumerate(["x", "y"]): index = item[f"{x_or_y}position"] * 2 + 4 * count diff --git a/src/misc.vala b/src/misc.vala index 58b6d5c74..764d157cd 100644 --- a/src/misc.vala +++ b/src/misc.vala @@ -5,4 +5,18 @@ namespace Graphs { "min-bottom", "max-bottom", "min-top", "max-top", "min-left", "max-left", "min-right", "max-right", }; + + public class Style : Object { + public string name { get; construct set; default = ""; } + // TODO: make non-nullable + public File? preview { get; set; } + public File? file { get; construct set; } + public bool mutable { get; construct set; } + + public Style (string name, File? file, File? preview, bool mutable) { + Object ( + name: name, file: file, preview: preview, mutable: mutable + ); + } + } } diff --git a/src/parse_file.py b/src/parse_file.py index cee95c650..28d1a714e 100644 --- a/src/parse_file.py +++ b/src/parse_file.py @@ -51,7 +51,8 @@ def import_from_xrdml(self, file): def import_from_xry(self, file): """Import data from .xry files used by Leybold X-ray apparatus.""" - lines = file_io.read_file(file, encoding="ISO-8859-1").splitlines() + with file_io.open_wrapped(file, "rt", encoding="ISO-8859-1") as wrapper: + lines = wrapper.readlines() if lines[0].strip() != "XR01": raise ParseError(_("Invalid .xry format")) @@ -102,54 +103,56 @@ def import_from_columns(self, file): delimiter = columns_params.get_string("delimiter") separator = columns_params.get_string("separator").replace(" ", "") skip_rows = columns_params.get_int("skip-rows") - lines = file_io.read_file(file).splitlines()[skip_rows:] - for index, line in enumerate(lines): - values = re.split(delimiter, line.strip()) - if separator == ",": - values = list(map(_swap, values)) - try: - if len(values) == 1: - float_value = utilities.string_to_float(values[0]) - if float_value is not None: - item_.ydata.append(float_value) - item_.xdata.append(index) - else: - try: - item_.xdata.append(utilities.string_to_float( - values[column_x])) - item_.ydata.append(utilities.string_to_float( - values[column_y])) - except IndexError as error: - raise ParseError( - _("Import failed, column index out of range"), - ) from error - # If not all values in the line are floats, start looking for - # headers instead - except ValueError: - # By default it will check for headers using at least - # two whitespaces as delimiter (often tabs), but if - # that doesn"t work it will try the same delimiter as - # used for the data import itself The reasoning is that - # some people use tabs for the headers, but e.g. commas - # for the data + with file_io.open_wrapped(file, "rt") as wrapper: + for index, line in enumerate(wrapper, -skip_rows): + if index < 0: + continue + values = re.split(delimiter, line.strip()) + if separator == ",": + values = list(map(_swap, values)) try: - headers = re.split("\\s{2,}", line) if len(values) == 1: - item_.ylabel = headers[column_x] + float_value = utilities.string_to_float(values[0]) + if float_value is not None: + item_.ydata.append(float_value) + item_.xdata.append(index) else: - item_.xlabel = headers[column_x] - item_.ylabel = headers[column_y] - except IndexError: + try: + item_.xdata.append(utilities.string_to_float( + values[column_x])) + item_.ydata.append(utilities.string_to_float( + values[column_y])) + except IndexError as error: + raise ParseError( + _("Import failed, column index out of range"), + ) from error + # If not all values in the line are floats, start looking for + # headers instead + except ValueError: + # By default it will check for headers using at least + # two whitespaces as delimiter (often tabs), but if + # that doesn"t work it will try the same delimiter as + # used for the data import itself The reasoning is that + # some people use tabs for the headers, but e.g. commas + # for the data try: - headers = re.split(delimiter, line) + headers = re.split("\\s{2,}", line) if len(values) == 1: item_.ylabel = headers[column_x] else: item_.xlabel = headers[column_x] item_.ylabel = headers[column_y] - # If neither heuristic works, we just skip headers except IndexError: - pass + try: + headers = re.split(delimiter, line) + if len(values) == 1: + item_.ylabel = headers[column_x] + else: + item_.xlabel = headers[column_x] + item_.ylabel = headers[column_y] + # If neither heuristic works, we just skip headers + except IndexError: + pass if not item_.xdata: raise ParseError(_("Unable to import from file")) return [item_] diff --git a/src/style_io.py b/src/style_io.py index 7b2de42d5..11130af73 100644 --- a/src/style_io.py +++ b/src/style_io.py @@ -2,12 +2,17 @@ import logging from gettext import gettext as _ +from gi.repository import Gio + from graphs import file_io, utilities -from matplotlib import RcParams, cbook +from matplotlib import RcParams, cbook, rc_context +from matplotlib.figure import Figure from matplotlib.font_manager import font_scalings, weight_dict from matplotlib.style.core import STYLE_BLACKLIST +import numpy + STYLE_IGNORELIST = [ "savefig.dpi", "savefig.facecolor", "savefig.edgecolor", "savefig.format", @@ -21,7 +26,7 @@ ] -def parse(file): +def parse(file: Gio.File) -> (RcParams, str): """ Parse a style to RcParams. @@ -32,11 +37,11 @@ def parse(file): style = RcParams() filename = utilities.get_filename(file) try: - lines = file_io.read_file(file).splitlines() - for line_number, line in enumerate(lines, 1): + wrapper = file_io.open_wrapped(file, "rt") + for line_number, line in enumerate(wrapper, 1): line = line.strip() if line_number == 2: - style.name = line[2:] + name = line[2:] line = cbook._strip_comment(line) if not line: continue @@ -80,7 +85,9 @@ def parse(file): message.format(filename, line_number)) except UnicodeDecodeError: logging.exception(_("Could not parse {}").format(filename)) - return style + finally: + wrapper.close() + return style, name WRITE_IGNORELIST = STYLE_IGNORELIST + [ @@ -90,17 +97,35 @@ def parse(file): ] -def write(file, style): - stream = file_io.get_write_stream(file) - file_io.write_string(stream, "# Generated via Graphs\n") - file_io.write_string(stream, f"# {style.name}\n") - for key, value in style.items(): - if key not in STYLE_BLACKLIST and key not in WRITE_IGNORELIST: - value = str(value).replace("#", "") - if key != "axes.prop_cycle": - value = value.replace("[", "").replace("]", "") - value = value.replace("'", "").replace("'", "") - value = value.replace('"', "").replace('"', "") - line = f"{key}: {value}\n" - file_io.write_string(stream, line) - stream.close() +def write(file: Gio.File, name: str, style: RcParams): + with file_io.open_wrapped(file, "wt") as wrapper: + wrapper.write("# Generated via Graphs\n") + wrapper.write(f"# {name}\n") + for key, value in style.items(): + if key not in STYLE_BLACKLIST and key not in WRITE_IGNORELIST: + value = str(value).replace("#", "") + if key != "axes.prop_cycle": + value = value.replace("[", "").replace("]", "") + value = value.replace("'", "").replace("'", "") + value = value.replace('"', "").replace('"', "") + wrapper.write(f"{key}: {value}\n") + + +_PREVIEW_XDATA = numpy.linspace(0, 10, 1000) +_PREVIEW_YDATA1 = numpy.sin(_PREVIEW_XDATA) +_PREVIEW_YDATA2 = numpy.cos(_PREVIEW_XDATA) + + +def generate_preview(style: RcParams) -> Gio.File: + file, stream = Gio.File.new_tmp(None) + with file_io.FileLikeWrapper.new_for_io_stream(stream) as wrapper, \ + rc_context(style): + # set render size in inch + figure = Figure(figsize=(5, 3)) + axis = figure.add_subplot() + axis.plot(_PREVIEW_XDATA, _PREVIEW_YDATA1) + axis.plot(_PREVIEW_XDATA, _PREVIEW_YDATA2) + axis.set_xlabel(_("X Label")) + axis.set_xlabel(_("Y Label")) + figure.savefig(wrapper, format="svg") + return file diff --git a/src/styles.py b/src/styles.py index 2d697d240..ffb92736b 100644 --- a/src/styles.py +++ b/src/styles.py @@ -12,25 +12,17 @@ from gi.repository import Adw, GLib, GObject, Gdk, Gio, Graphs, Gtk, Pango import graphs -from graphs import file_io, style_io, ui, utilities +from graphs import style_io, ui, utilities -from matplotlib import rc_context -from matplotlib.figure import Figure +from matplotlib import RcParams -import numpy - -PREVIEW_XDATA = numpy.linspace(0, 10, 1000) -PREVIEW_YDATA1 = numpy.sin(PREVIEW_XDATA) -PREVIEW_YDATA2 = numpy.cos(PREVIEW_XDATA) - - -def _compare_styles(a, b) -> int: - if a.file is None: +def _compare_styles(a: Graphs.Style, b: Graphs.Style) -> int: + if a.get_file() is None: return -1 - elif b.file is None: + elif b.get_file() is None: return 1 - return GLib.strcmp0(a.name.lower(), b.name.lower()) + return GLib.strcmp0(a.get_name().lower(), b.get_name().lower()) def _generate_filename(name: str) -> str: @@ -44,37 +36,34 @@ class StyleManager(GObject.Object, Graphs.StyleManagerInterface): application = GObject.Property(type=Graphs.Application) use_custom_style = GObject.Property(type=bool, default=False) custom_style = GObject.Property(type=str, default="adwaita") - gtk_theme = GObject.Property(type=str, default="") style_model = GObject.Property(type=Gio.ListStore) def __init__(self, application: Graphs.Application): + # Check for Ubuntu gtk_theme = Gtk.Settings.get_default().get_property("gtk-theme-name") + self._system_style_name = "Yaru" \ + if "SNAP" in os.environ and gtk_theme.lower().startswith("yaru") \ + else "Adwaita" super().__init__( - application=application, gtk_theme=gtk_theme.lower(), - style_model=Gio.ListStore.new(Style), + application=application, + style_model=Gio.ListStore.new(Graphs.Style), ) - self._update_styles() self._stylenames = [] - self._cache_dir = utilities.get_cache_directory() - if not self._cache_dir.query_exists(None): - self._cache_dir.make_directory_with_parents(None) - enumerator = self._cache_dir.enumerate_children("default::*", 0, None) - for file_info in enumerator: - enumerator.get_child(file_info).delete(None) - enumerator.close(None) directory = Gio.File.new_for_uri("resource:///se/sjoerd/Graphs/styles") enumerator = directory.enumerate_children("default::*", 0, None) for file in map(enumerator.get_child, enumerator): - style_params = style_io.parse(file) + style_params, name = style_io.parse(file) # TODO: bundle in distribution - preview = self._generate_preview(style_params) - self._stylenames.append(style_params.name) + preview = style_io.generate_preview(style_params) + self._stylenames.append(name) self.props.style_model.insert_sorted( - Style.new(style_params.name, file, preview, False), + Graphs.Style.new(name, file, preview, False), _compare_styles, ) enumerator.close(None) - self._system_style = Style.new(_("System"), None, None, False) + # TODO: add System style preview + self._system_style = Graphs.Style.new(_("System"), None, None, False) + self._update_system_style() self.props.style_model.insert(0, self._system_style) config_dir = utilities.get_config_directory() @@ -107,25 +96,26 @@ def __init__(self, application: Graphs.Application): ) self._on_style_change() - def _add_user_style(self, file: Gio.File, style_params=None): + def _add_user_style( + self, file: Gio.File, style_params: RcParams = None, name: str = None, + ) -> None: if style_params is None: - style_params = self.complete_style(style_io.parse(file)) - if style_params.name in self._stylenames: - style_params.name = utilities.get_duplicate_string( - style_params.name, self._stylenames, - ) + tmp_style_params, name = style_io.parse(file) + style_params = self.complete_style(tmp_style_params) + if name in self._stylenames: + new_name = utilities.get_duplicate_string(name, self._stylenames) file.delete(None) file = self._style_dir.get_child_for_display_name( - _generate_filename(style_params.name), + _generate_filename(new_name), ) - style_io.write(style_params, file) - preview = self._generate_preview(style_params) - self._stylenames.append(style_params.name) + style_io.write(style_params, new_name, file) + preview = style_io.generate_preview(style_params) + self._stylenames.append(name) self.props.style_model.insert_sorted( - Style.new(style_params.name, file, preview, True), _compare_styles, + Graphs.Style.new(name, file, preview, True), _compare_styles, ) - def get_style_model(self): + def get_style_model(self) -> Gio.ListStore: return self.props.style_model def get_stylenames(self) -> list: @@ -134,13 +124,15 @@ def get_stylenames(self) -> list: def get_style_dir(self) -> Gio.File: return self._style_dir - def get_selected_style_params(self): + def get_selected_style_params(self) -> RcParams: return self._selected_style_params - def get_system_style_params(self): + def get_system_style_params(self) -> RcParams: return self._system_style_params - def _on_file_change(self, _monitor, file, _other_file, event_type): + def _on_file_change( + self, _monitor, file: Gio.File, _other_file, event_type: int, + ) -> None: if Path(file.peek_path()).stem.startswith("."): return possible_visual_impact = False @@ -148,36 +140,38 @@ def _on_file_change(self, _monitor, file, _other_file, event_type): style_model = self.get_style_model() if event_type == 2: for index, style in enumerate(style_model): - if style.file is not None and file.equal(style.file): - self._stylenames.remove(style.name) + file2 = style.get_file() + if file2 is not None and file.equal(file2): + stylename = style.get_name() + self._stylenames.remove(stylename) style_model.remove(index) - stylename = style.name break if stylename is None: return possible_visual_impact = True else: - style_params = self.complete_style(style_io.parse(file)) - stylename = style_params.name + tmp_style_params, stylename = style_io.parse(file) + style_params = self.complete_style(tmp_style_params) if event_type == 1: for obj in style_model: - if obj.name == stylename: - obj.preview = self._generate_preview(style_params) + if obj.get_name() == stylename: + obj.set_preview(style_io.generate_preview(style_params)) break possible_visual_impact = False elif event_type == 3: - self._add_user_style(file, style_params) + self._add_user_style(file, style_params, stylename) if possible_visual_impact \ and self.props.use_custom_style \ and self.props.custom_style == stylename: self._on_style_change() - def _on_style_select(self, _a, _b): + def _on_style_select(self, _a, _b) -> None: settings = self.props.application.get_settings("general") self._on_style_change(settings.get_boolean("override-item-properties")) - def _on_style_change(self, override=False): - self._update_styles() + def _on_style_change(self, override: bool = False) -> None: + self._update_system_style() + self._update_selected_style() data = self.props.application.get_data() if override: color_cycle = self._selected_style_params[ @@ -206,65 +200,47 @@ def _on_style_change(self, override=False): "sensitive", canvas, "highlight_enabled", 2, ) - def _update_styles(self): - # Check for Ubuntu - system_style = "Yaru" if "SNAP" in os.environ \ - and self.props.get_gtk_theme.startswith("yaru") else "Adwaita" + def _update_system_style(self) -> None: + system_style = self._system_style_name if Adw.StyleManager.get_default().get_dark(): system_style += " Dark" filename = _generate_filename(system_style) self._system_style_params = style_io.parse(Gio.File.new_for_uri( "resource:///se/sjoerd/Graphs/styles/" + filename, - )) + ))[0] + def _update_selected_style(self) -> None: self._selected_style_params = None - window = self.props.application.get_window() if self.props.use_custom_style: stylename = self.props.custom_style for style in self.props.style_model: - if stylename == style.name: + if stylename == style.get_name(): try: - style_params = style_io.parse(style.file) - if style.mutable: + style_params = style_io.parse(style.get_file())[0] + if style.get_mutable(): style_params = self.complete_style(style_params) self._selected_style_params = style_params return except (ValueError, SyntaxError, AttributeError): - self.props.custom_style = system_style - self.props.use_custom_style = False - window.add_toast_string( + self._reset_selected_style( _(f"Could not parse {stylename}, loading " "system preferred style").format( stylename=stylename), ) break - window.add_toast_string( - _(f"Plot style {stylename} does not exist " - "loading system preferred").format(stylename=stylename)) - self.props.custom_style = system_style - self.props.use_custom_style = False + if self._selected_style_params is None: + self._reset_selected_style( + _(f"Plot style {stylename} does not exist " + "loading system preferred").format(stylename=stylename), + ) self._selected_style_params = self._system_style_params - def _generate_preview(self, style: dict) -> Gio.File: - with rc_context(style): - # set render size in inch - figure = Figure(figsize=(5, 3)) - axis = figure.add_subplot() - axis.plot(PREVIEW_XDATA, PREVIEW_YDATA1) - axis.plot(PREVIEW_XDATA, PREVIEW_YDATA2) - axis.set_xlabel(_("X Label")) - axis.set_xlabel(_("Y Label")) - buffer = io.BytesIO() - figure.savefig(buffer, format="svg") - file = \ - self._cache_dir.get_child_for_display_name(f"{style.name}.svg") - stream = file_io.get_write_stream(file) - stream.write(buffer.getvalue()) - buffer.close() - stream.close() - return file - - def copy_style(self, template: str, new_name: str): + def _reset_selected_style(self, message: str) -> None: + self.props.use_custom_style = False + self.props.custom_style = self._system_style_name + self.props.application.get_window().add_toast_string(message) + + def copy_style(self, template: str, new_name: str) -> None: new_name = utilities.get_duplicate_string( new_name, self._stylenames, ) @@ -272,31 +248,20 @@ def copy_style(self, template: str, new_name: str): _generate_filename(new_name), ) for style in self.props.style_model: - if template == style.name: - source = self.complete_style(style_io.parse(style.file)) \ - if style.mutable else style_io.parse(style.file) + if template == style.get_name(): + style_params = style_io.parse(style.get_file())[0] + source = self.complete_style(style_params) \ + if style.get_mutable() else style_params break - source.name = new_name - style_io.write(destination, source) + style_io.write(destination, new_name, source) - def complete_style(self, params): + def complete_style(self, params: RcParams) -> RcParams: for key, value in self._system_style_params.items(): if key not in params: params[key] = value return params -class Style(GObject.Object): - name = GObject.Property(type=str, default="") - preview = GObject.Property(type=Gio.File) - file = GObject.Property(type=Gio.File) - mutable = GObject.Property(type=bool, default=False) - - @staticmethod - def new(name, file, preview, mutable): - return Style(name=name, file=file, preview=preview, mutable=mutable) - - @Gtk.Template(resource_path="/se/sjoerd/Graphs/ui/style_preview.ui") class StylePreview(Gtk.AspectFrame): __gtype_name__ = "GraphsStylePreview" @@ -311,7 +276,7 @@ def __init__(self, **kwargs): self.provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) - @GObject.Property(type=Style) + @GObject.Property(type=Graphs.Style) def style(self): return self._style @@ -339,7 +304,7 @@ def preview(self, file): return texture = Gdk.Texture.new_from_file(file) self.picture.set_paintable(texture) - if self._style.mutable: + if self._style.get_mutable(): buffer = io.BytesIO(texture.save_to_png_bytes().get_data()) mean = ImageStat.Stat(Image.open(buffer).convert("L")).mean[0] buffer.close() @@ -491,14 +456,15 @@ def __init__(self, parent): button.provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def load_style(self, style): - if not style.mutable: + if not style.get_mutable(): return self.style = style self.style_params = self._style_manager.complete_style( - style_io.parse(self.style.file), + style_io.parse(self.style.get_file())[0], ) - self.set_title(self.style.name) - self.style_name.set_text(self.style.name) + stylename = self.style.get_name() + self.set_title(stylename) + self.style_name.set_text(stylename) ui.load_values_from_dict(self, { key: VALUE_DICT[key].index(self.style_params[value[0]]) if key in VALUE_DICT else self.style_params[value[0]] @@ -572,13 +538,12 @@ def save_style(self): # name & save application = self.parent.get_application() new_name = self.style_name.get_text() - if self.style.name != new_name: + if self.style.get_name() != new_name: style_manager = application.get_figure_style_manager() new_name = utilities.get_duplicate_string( new_name, style_manager.get_stylenames(), ) - self.style_params.name = new_name - style_io.write(self.style.file, self.style_params) + style_io.write(self.style.get_file(), new_name, self.style_params) self.style = None def reload_line_colors(self): @@ -613,7 +578,7 @@ def add_color(self, _button): @Gtk.Template.Callback() def on_delete(self, _button): - self.style.file.trash(None) + self.style.get_file().trash(None) self.style = None self.parent.navigation_view.pop() diff --git a/src/ui.py b/src/ui.py index efed9a9c4..7548383f5 100644 --- a/src/ui.py +++ b/src/ui.py @@ -112,6 +112,7 @@ def build_dialog(name): def show_about_window(self): + file = Gio.File.new_for_uri("resource:///se/sjoerd/Graphs/whats_new") Adw.AboutWindow( transient_for=self.get_window(), application_name=self.get_name(), application_icon=self.get_application_id(), website=self.get_website(), @@ -123,8 +124,7 @@ def show_about_window(self): copyright=f"© 2022 – {datetime.date.today().year} {self.get_author()}", license_type="GTK_LICENSE_GPL_3_0", translator_credits=_("translator-credits"), - release_notes=file_io.read_file( - Gio.File.new_for_uri("resource:///se/sjoerd/Graphs/whats_new")), + release_notes=file.load_bytes(None)[0].get_data().decode("utf-8"), ).present() diff --git a/src/utilities.py b/src/utilities.py index 6249f20d1..eb4c0a6df 100644 --- a/src/utilities.py +++ b/src/utilities.py @@ -111,11 +111,6 @@ def get_config_directory(): return main_directory.get_child_for_display_name("graphs") -def get_cache_directory(): - main_directory = Gio.File.new_for_path(GLib.get_user_cache_dir()) - return main_directory.get_child_for_display_name("graphs") - - def create_file_filters(filters, add_all=True): list_store = Gio.ListStore() for name, suffix_list in filters: