diff --git a/data/bookmark-filled-symbolic.svg b/data/bookmark-filled-symbolic.svg new file mode 100644 index 0000000..f877488 --- /dev/null +++ b/data/bookmark-filled-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/bookmark-outline-symbolic.svg b/data/bookmark-outline-symbolic.svg new file mode 100644 index 0000000..b9df560 --- /dev/null +++ b/data/bookmark-outline-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/data/meson.build b/data/meson.build index 75f3585..94adc9a 100644 --- a/data/meson.build +++ b/data/meson.build @@ -30,6 +30,14 @@ install_data('dev.tchx84.Portfolio-symbolic.svg', install_dir: join_paths(get_option('datadir'), 'icons/hicolor/symbolic/apps') ) +install_data('bookmark-filled-symbolic.svg', + install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps') +) + +install_data('bookmark-outline-symbolic.svg', + install_dir: join_paths(get_option('datadir'), 'icons/hicolor/symbolic/apps') +) + appstreamcli = find_program('appstreamcli', required: false) if appstreamcli.found() test('Validate appstream file', appstreamcli, diff --git a/src/bookmarks.py b/src/bookmarks.py new file mode 100644 index 0000000..0806efa --- /dev/null +++ b/src/bookmarks.py @@ -0,0 +1,109 @@ +# devices.py +# +# Copyright 2025 Jason Beetham +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from gi.repository import GLib, GObject, Gtk + +import json, os + + +class PortfolioBookmarks(GObject.GObject): + __gtype_name__ = "PortfolioBookmarks" + + __gsignals__ = { + "add-bookmark": (GObject.SignalFlags.RUN_LAST, None, (str,)), + "remove-bookmark": (GObject.SignalFlags.RUN_LAST, None, (str,)), + } + + def __init__(self): + GObject.GObject.__init__(self) + + self.bookmarked = set() + self._portfolio_config_path = os.path.join( + GLib.get_user_config_dir(), "portfolio" + ) + self._bookmark_path = os.path.join(self._portfolio_config_path, "bookmarks") + + try: + with open(self._bookmark_path, "r") as f: + data = f.read() + if not data is None: + bookmarked = json.loads(data) + for val in bookmarked: + if type(val) is not str: + bookmarked = [] + break + + for path in bookmarked: + self._add_bookmark(path) + + except (json.JSONDecodeError, OSError, IOError): + self.bookmarked = set() + + def _add_bookmark(self, path): + if path not in self.bookmarked: + self.bookmarked.add(path) + self.emit("add-bookmark", path) + + def _delete_bookmark(self, path): + if path in self.bookmarked: + self.bookmarked.remove(path) + self.emit("remove-bookmark", path) + + def is_bookmarked(self, path): + return path in self.bookmarked + + def _save_bookmarks(self): + os.makedirs(self._portfolio_config_path, exist_ok=True) + with open(self._bookmark_path, "w") as f: + paths = [bookmark for bookmark in self.bookmarked] + json.dump(paths, f) + + def toggle_bookmark(self, button, path): + if self.is_bookmarked(path): + self._delete_bookmark(path) + else: + self._add_bookmark(path) + self._save_bookmarks() + + +class PortfolioBookmarkButton(Gtk.Button): + __gtype_name__ = "PortfolioBookmarkButton" + + def __init__(self, bookmarks): + Gtk.Button.__init__(self) + self.connect("clicked", self._on_bookmark_toggle) + self._bookmarks = bookmarks + self.props.icon_name = "bookmark-filled-symbolic" + + def _change_icon(self): + if self._bookmarks.is_bookmarked(self._path): + self.props.icon_name = "bookmark-filled-symbolic" + else: + self.props.icon_name = "bookmark-outline-symbolic" + + def _on_bookmark_toggle(self, button): + self._bookmarks.toggle_bookmark(button, self._path) + self._change_icon() + + @property + def path(self): + self._path + + @path.setter + def path(self, path): + self._path = path + self._change_icon() diff --git a/src/meson.build b/src/meson.build index 64e397b..eb03d66 100644 --- a/src/meson.build +++ b/src/meson.build @@ -51,6 +51,7 @@ portfolio_sources = [ 'loading.py', 'files.py', 'menu.py', + 'bookmarks.py' ] install_data(portfolio_sources, install_dir: moduledir) diff --git a/src/place.py b/src/place.py index 98e228c..93db18d 100644 --- a/src/place.py +++ b/src/place.py @@ -24,6 +24,7 @@ class PortfolioPlace(Adw.ActionRow): icon = Gtk.Template.Child() eject = Gtk.Template.Child() + remove_bookmark = Gtk.Template.Child() mount = None path = "" uuid = "" diff --git a/src/place.ui b/src/place.ui index ee51648..989de90 100644 --- a/src/place.ui +++ b/src/place.ui @@ -25,6 +25,20 @@ + + 0 + 1 + end + center + + + bookmark-outline-symbolic + + + + diff --git a/src/places.py b/src/places.py index 80650f0..157efa4 100644 --- a/src/places.py +++ b/src/places.py @@ -23,6 +23,7 @@ from . import logger from .place import PortfolioPlace from .devices import PortfolioDevices +from .bookmarks import PortfolioBookmarks from .translation import gettext as _ @@ -60,14 +61,14 @@ class PortfolioPlaces(Gtk.Stack): VIDEOS_PERMISSION = ["host", "home", "xdg-videos"] TRASH_PERMISSION = ["host", "home"] - def __init__(self, **kargs): + def __init__(self, bookmarks, **kargs): super().__init__(**kargs) - self._setup() + self._setup(bookmarks) - def _setup(self): + def _setup(self, bookmarks): self.props.visible = True self.props.transition_type = Gtk.StackTransitionType.CROSSFADE - + self._bookmarks = bookmarks self._permissions = None self._devices = PortfolioDevices() @@ -75,6 +76,8 @@ def _setup(self): self._devices.connect("removed", self._on_device_removed) self._devices.connect("encrypted-added", self._on_encrypted_added) + self._bookmarks = PortfolioBookmarks() + # begin UI structure self._groups_box = Gtk.Box() @@ -94,6 +97,10 @@ def _setup(self): self._devices_group.props.visible = True self._devices_group.get_style_context().add_class("devices-group") + self._bookmarks_group = Adw.PreferencesGroup() + self._bookmarks_group.props.title = _("Bookmarks") + self._bookmarks_group.props.visible = True + # places if self._has_permission_for(self.HOME_PERMISSION): @@ -166,10 +173,15 @@ def _setup(self): self._groups_box.append(self._places_group) self._groups_box.append(self._devices_group) + self._groups_box.append(self._bookmarks_group) self._places_listbox = utils.find_child_by_id(self._places_group, "listbox") self._devices_listbox = utils.find_child_by_id(self._devices_group, "listbox") + self._bookmarks_listbox = utils.find_child_by_id( + self._bookmarks_group, "listbox" + ) + self._add_accessible_bookmarks() # no places message message = Gtk.Label() @@ -192,6 +204,9 @@ def _setup(self): self._update_visibility() + bookmarks.connect("add-bookmark", self._on_bookmark_added) + bookmarks.connect("remove-bookmark", self._on_bookmark_removed) + def _update_visibility(self): self._update_stack_visibility() self._update_places_group_visibility() @@ -214,6 +229,10 @@ def _update_device_group_visibility(self): visible = len(list(self._devices_listbox)) >= 1 self._devices_group.props.visible = visible + def _update_bookmarks_group_visibility(self): + visible = len(list(self._bookmarks_listbox)) >= 1 + self._bookmarks_group.props.visible = visible + def _get_permissions(self): if self._permissions is not None: return self._permissions @@ -384,3 +403,37 @@ def _on_encrypted_eject_finished(self, encrypted, success): self._on_device_removed(None, encrypted) else: self.emit("failed", None) + + def _find_place_by_path(self, listbox, path): + for place in listbox: + if place.path == path: + return place + return None + + def _on_bookmark_removed(self, bookmark, path): + place = self._find_place_by_path(self._bookmarks_listbox, path) + if place is not None: + self._bookmarks_group.remove(place) + + def _on_bookmark_added(self, bookmark, path): + if self._find_place_by_path(self._bookmarks_listbox, path) is None: + self._add_bookmark_place(path) + + def _add_accessible_bookmarks(self): + for path in self._bookmarks.bookmarked: + not_added = self._find_place_by_path(self._bookmarks_listbox, path) is None + if not_added and os.path.isdir( + path + ): # Might be a bookmarked path on a unmounted device + self._add_bookmark_place(path) + + def _add_bookmark_place(self, path): + name = os.path.basename(path) + place = self._add_place( + self._bookmarks_group, + "bookmark-filled-symbolic", + name, + path, + ) + place.remove_bookmark.props.visible = True + place.remove_bookmark.connect("clicked", self._on_bookmark_removed, path) diff --git a/src/window.py b/src/window.py index 9aa9412..a60593e 100644 --- a/src/window.py +++ b/src/window.py @@ -43,6 +43,7 @@ from .files import PortfolioFiles from .menu import PortfolioMenu from .settings import PortfolioSettings +from .bookmarks import PortfolioBookmarks, PortfolioBookmarkButton from .trash import default_trash @@ -61,6 +62,7 @@ class PortfolioWindow(Adw.ApplicationWindow): paste = Gtk.Template.Child() select_all = Gtk.Template.Child() select_none = Gtk.Template.Child() + bookmark_box = Gtk.Template.Child() new_folder = Gtk.Template.Child() delete_trash = Gtk.Template.Child() restore_trash = Gtk.Template.Child() @@ -148,6 +150,7 @@ def _setup(self): self.select_all.connect("clicked", self._on_select_all) self.select_none.connect("clicked", self._on_select_none) self.new_folder.connect("clicked", self._on_new_folder) + self.close_button.connect("clicked", self._on_button_closed) self.go_top_button.connect("clicked", self._go_to_top) self.stop_button.connect("clicked", self._on_stop_clicked) @@ -181,8 +184,12 @@ def _setup(self): self.files.connect("adjustment-changed", self._on_files_adjustment_changed) self.files.sort_order = self._settings.sort_order self.content_inner_box.append(self.files) + self._bookmarks = PortfolioBookmarks() + + self._bookmark_button = PortfolioBookmarkButton(self._bookmarks) + self.bookmark_box.append(self._bookmark_button) - places = PortfolioPlaces() + places = PortfolioPlaces(self._bookmarks) places.connect("updated", self._on_places_updated) places.connect("removing", self._on_places_removing) places.connect("removed", self._on_places_removed) @@ -224,6 +231,7 @@ def _populate(self, directory): self._worker.connect("finished", self._on_load_finished) self._worker.connect("failed", self._on_load_failed) self._worker.start() + self._bookmark_button.path = directory def _paste(self, Worker, to_paste): directory = self._history[self._index] diff --git a/src/window.ui b/src/window.ui index ecdf3b1..9a5b8e1 100644 --- a/src/window.ui +++ b/src/window.ui @@ -360,6 +360,13 @@ end + + + + + + + 0