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 @@
+
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