├── .github └── workflows │ ├── ci.yml │ └── publish-release.yml ├── .gitignore ├── .pylintrc ├── CODE_OF_CONDUCT.md ├── COPYING ├── README.md ├── data ├── icons │ ├── hicolor │ │ ├── scalable │ │ │ └── apps │ │ │ │ ├── page.kramo.Hyperplane.Devel.svg │ │ │ │ └── page.kramo.Hyperplane.svg │ │ └── symbolic │ │ │ └── apps │ │ │ ├── page.kramo.Hyperplane-symbolic.svg │ │ │ └── page.kramo.Hyperplane.Devel-symbolic.svg │ ├── meson.build │ └── scalable │ │ └── actions │ │ ├── tag-outline-add-symbolic.svg │ │ └── tag-outline-symbolic.svg ├── meson.build ├── org.freedesktop.FileManager1.service.in ├── page.kramo.Hyperplane.desktop.in ├── page.kramo.Hyperplane.gschema.xml.in ├── page.kramo.Hyperplane.metainfo.xml.in └── screenshots │ └── 1.png ├── hyperplane.doap ├── hyperplane ├── __builtins__.pyi ├── __init__.py ├── assets │ ├── folder-closed.svg │ ├── folder-open.svg │ ├── welcome-folders.svg │ └── welcome-tags.svg ├── devel │ └── timer.py ├── editable_row.py ├── file_manager_dbus.py ├── file_properties.py ├── gtk │ ├── guide.blp │ ├── help-overlay.blp │ ├── item.blp │ ├── items-page.blp │ ├── new-file-dialog.blp │ ├── path-bar.blp │ ├── path-entry.blp │ ├── path-segment.blp │ ├── preferences.blp │ ├── style-dark.css │ ├── style.css │ ├── volumes-box.blp │ └── window.blp ├── guide.py ├── hover_page_opener.py ├── hyperplane.gresource.xml.in ├── hyperplane.in ├── item.py ├── item_filter.py ├── item_sorter.py ├── items_page.py ├── logging │ ├── color_log_formatter.py │ └── logging_config.py ├── main.py ├── meson.build ├── navigation_bin.py ├── new_file_dialog.py ├── path_bar.py ├── path_entry.py ├── path_segment.py ├── postmaster_general.py ├── preferences.py ├── properties.py ├── shared.py.in ├── tag_row.py ├── utils │ ├── create_alert_dialog.py │ ├── dates.py │ ├── files.py │ ├── iterplane.py │ ├── symbolics.py │ ├── tags.py │ ├── thumbnail.py │ └── undo.py ├── volumes_box.py └── window.py ├── meson.build ├── meson_options.txt ├── page.kramo.Hyperplane.Devel.json ├── po ├── LINGUAS ├── POTFILES └── meson.build └── subprojects └── blueprint-compiler.wrap /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main] 4 | pull_request: 5 | name: CI 6 | concurrency: 7 | group: release-${{ github.sha }} 8 | jobs: 9 | flatpak: 10 | name: Flatpak 11 | runs-on: ubuntu-latest 12 | container: 13 | image: bilelmoussaoui/flatpak-github-actions:gnome-45 14 | options: --privileged 15 | strategy: 16 | matrix: 17 | arch: [x86_64, aarch64] 18 | # Don't fail the whole workflow if one architecture fails 19 | fail-fast: false 20 | steps: 21 | - uses: actions/checkout@v4 22 | # Docker is required by the docker/setup-qemu-action which enables emulation 23 | - name: Install deps 24 | if: ${{ matrix.arch != 'x86_64' }} 25 | run: | 26 | dnf -y install docker 27 | - name: Set up QEMU 28 | if: ${{ matrix.arch != 'x86_64' }} 29 | id: qemu 30 | uses: docker/setup-qemu-action@v3.0.0 31 | with: 32 | platforms: arm64 33 | - name: Flatpak Builder 34 | uses: flatpak/flatpak-github-actions/flatpak-builder@v6 35 | with: 36 | bundle: page.kramo.Hyperplane.Devel.flatpak 37 | manifest-path: page.kramo.Hyperplane.Devel.json 38 | repository-url: https://nightly.gnome.org/gnome-nightly.flatpakrepo 39 | repository-name: gnome-nightly 40 | arch: ${{ matrix.arch }} 41 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | "*" 5 | name: Publish Release 6 | concurrency: 7 | group: release-${{ github.sha }} 8 | jobs: 9 | publish-release: 10 | name: Publish Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | 16 | - name: Download workflow artifact 17 | uses: dawidd6/action-download-artifact@v6 18 | with: 19 | workflow: ci.yml 20 | commit: ${{ github.sha }} 21 | 22 | - name: Get release notes 23 | shell: python 24 | run: | 25 | import re, textwrap 26 | open_file = open("./data/page.kramo.Hyperplane.metainfo.xml.in", "r", encoding="utf-8") 27 | string = open_file.read() 28 | open_file.close() 29 | string = re.findall("\s*\n([\s\S]*?)\s*\s*<\/release>", string)[0] 30 | string = textwrap.dedent(string) 31 | open_file = open("release_notes", "w", encoding="utf-8") 32 | open_file.write(string) 33 | open_file.close() 34 | 35 | - name: Get tag name 36 | id: get_tag_name 37 | run: echo tag_name=${GITHUB_REF#refs/tags/} >> $GITHUB_OUTPUT 38 | 39 | - name: Rename bundles 40 | id: rename_bundles 41 | run: | 42 | mv page.kramo.Hyperplane.Devel-x86_64/page.kramo.Hyperplane.Devel.flatpak page.kramo.Hyperplane.Devel-x86_64.flatpak 43 | mv page.kramo.Hyperplane.Devel-aarch64/page.kramo.Hyperplane.Devel.flatpak page.kramo.Hyperplane.Devel-aarch64.flatpak 44 | 45 | - name: Publish release 46 | uses: softprops/action-gh-release@v0.1.15 47 | with: 48 | files: | 49 | page.kramo.Hyperplane.Devel-x86_64.flatpak 50 | page.kramo.Hyperplane.Devel-aarch64.flatpak 51 | tag_name: ${{ steps.get_tag_name.outputs.tag_name }} 52 | body_path: release_notes 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /subprojects/blueprint-compiler 2 | .flatpak-builder 3 | .flatpak 4 | .vscode 5 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | 3 | disable=import-error, 4 | no-name-in-module 5 | 6 | 7 | [TYPECHECK] 8 | 9 | ignored-classes=Child 10 | 11 | 12 | [VARIABLES] 13 | 14 | additional-builtins=_ 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | The project follows the [GNOME Code of Conduct](https://conduct.gnome.org/). 2 | 3 | If you believe that someone is violating the Code of Conduct, or have any other concerns, please contact us via [hyperplane-community@kramo.page](mailto:hyperplane-community@kramo.page). 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!IMPORTANT] 2 | > Please use [Codeberg](https://codeberg.org/kramo/hyperplane) for issues and pull requests. 3 | > The GitHub repository is a [mirror](https://en.wikipedia.org/wiki/Mirror_site). 4 | 5 | > [!NOTE] 6 | > # State of the Project 7 | > This app is currently not actively maintained as I am focused on other projects. I do plan on either picking it up or exploring similar concepts in the future, so I will not archive the repository but I will probably not fix any active issues or accept pull requests for the time being. 8 | 9 | 10 |
11 | 12 | 13 | # Hyperplane 14 | 15 | A non-hierarchical file manager 16 | 17 | 18 |
19 | 20 | > [!WARNING] 21 | > This project is currently in **BETA**. You can try it out at your own risk, but be aware that things might break, it might have annoyances, **DATA LOSS** may occur and it may kill your cat. 22 | 23 | For now, I recommend only trying it if you have a recent backup of your files. 24 | 25 | # The project 26 | 27 | The problem is that current methods for tagging files are OS-, file system- or application-specific and not portable. 28 | 29 | The app was primarily built as a proof of concept for a non-hierarchical file manager whose storage can still be conveniently browsed via conventional file managers. 30 | 31 | It is also a playground for design ideas like file extension badges or a symbolic grid view. 32 | 33 | ## The concept 34 | 35 | Hyperplane stores its 'tags' (called categories) on disk as regular directories. 36 | 37 | File A tagged 'Pictures', 'Art' and 'Animals' would be stored at `/Pictures/Art/Animals/` on disk. 38 | 39 | File B tagged 'Videos' and 'Art' would be stored at `/Videos/Art/`. 40 | 41 | When filtering for files tagged 'Art' however, both of these would show up. 42 | 43 | The app keeps track of the list of categories in a `.hyperplane` file at the root of the Hyperplane directory. (Which is `$HOME` by default, but can be changed with the `$HYPHOME` environment variable.) 44 | 45 | ## The name 46 | 47 | https://en.wikipedia.org/wiki/Hyperplane 48 | 49 | It is subject to change. 50 | 51 | # Testing 52 | 53 | The project is currently in beta. Most features work, but user experience still needs refinement. 54 | 55 | If you want to test without risking data loss, please set the `$HYPHOME` environment variable to point to somewhere inside `~/.var/app/page.kramo.Hyperplane.Devel/` and remove the app's `--filesystem=host` access. 56 | 57 | You can download the latest beta from the GitHub [Releases page](https://github.com/kra-mo/hyperplane/releases) or the latest in-development version from [here](https://nightly.link/kra-mo/hyperplane/workflows/ci/main/page.kramo.Hyperplane.Devel-x86_64.zip). 58 | 59 | You will need the [GNOME Nightly](https://nightly.gnome.org/) runtime installed to be able to test the app. 60 | 61 | Please report any and all issues you find! 62 | 63 | UX suggestions and missing feature reports are also welcome, even if it seems obvious. 64 | 65 | # Contributing 66 | 67 | If you want to help with code or design, please reach out or file an issue before making a pull request. That being said, I appreciate any help! 68 | 69 | ## Code 70 | 71 | ### Building 72 | 73 | ```sh 74 | git clone https://git.kramo.page/hyperplane.git 75 | cd hyperplane 76 | meson setup build 77 | ninja -C build install 78 | ``` 79 | 80 | ### Code style 81 | 82 | All code is auto-formatted with [Black](https://github.com/psf/black) and linted with [Pylint](https://github.com/pylint-dev/pylint). Imports are sorted by [isort](https://github.com/pycqa/isort). 83 | 84 | VSCode extensions are available for all of these and you can set them up with the following `settings.json` configuration: 85 | 86 | ```json 87 | "python.formatting.provider": "none", 88 | "[python]": { 89 | "editor.defaultFormatter": "ms-python.black-formatter", 90 | "editor.formatOnSave": true, 91 | "editor.codeActionsOnSave": { 92 | "source.organizeImports": true 93 | }, 94 | }, 95 | "isort.args":["--profile", "black"], 96 | ``` 97 | 98 | For other code editors, you can install them via `pip` and invoke them from the command line. 99 | 100 | ## Translations 101 | 102 | Strings are not final yet, I will set up translations closer to an initial release. 103 | 104 | # Code of Conduct 105 | 106 | The project follows the [GNOME Code of Conduct](https://conduct.gnome.org/). 107 | 108 | See [CODE_OF_CONDUCT.md](https://codeberg.org/kramo/hyperplane/src/branch/main/CODE_OF_CONDUCT.md). 109 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/page.kramo.Hyperplane.Devel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/hicolor/scalable/apps/page.kramo.Hyperplane.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/page.kramo.Hyperplane-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/hicolor/symbolic/apps/page.kramo.Hyperplane.Devel-symbolic.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | scalable_dir = join_paths('hicolor', 'scalable', 'apps') 2 | install_data( 3 | join_paths(scalable_dir, ('@0@.svg').format(app_id)), 4 | install_dir: join_paths(get_option('datadir'), 'icons', scalable_dir) 5 | ) 6 | 7 | symbolic_dir = join_paths('hicolor', 'symbolic', 'apps') 8 | install_data( 9 | join_paths(symbolic_dir, ('@0@-symbolic.svg').format(app_id)), 10 | install_dir: join_paths(get_option('datadir'), 'icons', symbolic_dir) 11 | ) 12 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/tag-outline-add-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/icons/scalable/actions/tag-outline-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | desktop_file = i18n.merge_file( 2 | input: configure_file( 3 | input: 'page.kramo.Hyperplane.desktop.in', 4 | output: app_id + '.desktop.in', 5 | configuration: conf 6 | ), 7 | output: app_id + '.desktop', 8 | type: 'desktop', 9 | po_dir: '../po', 10 | install: true, 11 | install_dir: get_option('datadir') / 'applications' 12 | ) 13 | 14 | desktop_utils = find_program('desktop-file-validate', required: false) 15 | if desktop_utils.found() 16 | test('Validate desktop file', desktop_utils, args: [desktop_file]) 17 | endif 18 | 19 | servicedir = get_option('prefix') / get_option('datadir') / 'dbus-1' / 'services' 20 | 21 | 22 | configure_file( 23 | input: 'org.freedesktop.FileManager1.service.in', 24 | output: 'org.freedesktop.FileManager1.service', 25 | configuration: conf, 26 | install_dir: servicedir 27 | ) 28 | 29 | 30 | appstream_file = i18n.merge_file( 31 | input: configure_file( 32 | input: 'page.kramo.Hyperplane.metainfo.xml.in', 33 | output: app_id + '.metainfo.xml.in', 34 | configuration: conf 35 | ), 36 | output: app_id + '.metainfo.xml', 37 | po_dir: '../po', 38 | install: true, 39 | install_dir: get_option('datadir') / 'metainfo' 40 | ) 41 | 42 | appstreamcli = find_program('appstreamcli', required: false, disabler: true) 43 | test('Validate appstream file', 44 | appstreamcli, 45 | args: ['validate', '--no-net', '--explain', appstream_file], 46 | workdir: meson.current_build_dir() 47 | ) 48 | 49 | install_data( 50 | configure_file( 51 | input: 'page.kramo.Hyperplane.gschema.xml.in', 52 | output: app_id + '.gschema.xml', 53 | configuration: conf 54 | ), 55 | install_dir: get_option('datadir') / 'glib-2.0' / 'schemas' 56 | ) 57 | 58 | compile_schemas = find_program('glib-compile-schemas', required: false, disabler: true) 59 | test('Validate schema file', 60 | compile_schemas, 61 | args: ['--strict', '--dry-run', meson.current_source_dir()]) 62 | 63 | subdir('icons') 64 | -------------------------------------------------------------------------------- /data/org.freedesktop.FileManager1.service.in: -------------------------------------------------------------------------------- 1 | [D-BUS Service] 2 | Name=org.freedesktop.FileManager1 3 | Exec=@prefix@/@bindir@/hyperplane --gapplication-service 4 | -------------------------------------------------------------------------------- /data/page.kramo.Hyperplane.desktop.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Hyperplane 3 | Keywords=file;folder;manager;explore; 4 | Exec=hyperplane --new-window %U 5 | Icon=@APP_ID@ 6 | Terminal=false 7 | Type=Application 8 | Categories=GNOME;GTK;System;Core;FileTools;FileManager; 9 | MimeType=inode/directory; 10 | StartupNotify=true 11 | Actions=new-window; 12 | 13 | [Desktop Action new-window] 14 | Name=New Window 15 | Exec=hyperplane --new-window 16 | -------------------------------------------------------------------------------- /data/page.kramo.Hyperplane.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | true 15 | 16 | 17 | false 18 | 19 | 20 | [] 21 | 22 | 23 | 24 | 25 | 26 | true 27 | 28 | 29 | 880 30 | 31 | 32 | 550 33 | 34 | 35 | false 36 | 37 | 38 | false 39 | 40 | 41 | 'a-z' 42 | 43 | 44 | false 45 | 46 | 47 | true 48 | 49 | 50 | 3 51 | 52 | 53 | 0 54 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /data/page.kramo.Hyperplane.metainfo.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | @APP_ID@ 4 | CC0-1.0 5 | GPL-3.0-or-later 6 | Hyperplane 7 | Organize your files freely 8 | 9 |

Hyperplane is a non-hierarchical file manager. It allows you to organize your files by placing them in multiple categories at once while still being able to access these categories from traditional file managers.

10 |
11 | https://git.kramo.page/hyperplane 12 | https://git.kramo.page/hyperplane/issues 13 | https://kramo.page/links 14 | https://git.kramo.page/hyperplane 15 | 16 | kramo 17 | 18 | kramo 19 | 20 | @APP_ID@.desktop 21 | hyperplane 22 | 23 | #99c1f1 24 | #4f1f74 25 | 26 | 27 | pointing 28 | keyboard 29 | touch 30 | 31 | 32 | 33 | Hyperplane 34 | https://raw.githubusercontent.com/kra-mo/hyperplane/main/data/screenshots/1.png 35 | 36 | 37 | 38 | 39 | 40 | 41 |
    42 |
  • Fixed drag and drop on the latest nightly runtime
  • 43 |
  • The cursor now indicates that empty space in the path bar is clickable
  • 44 |
  • Updated the sidebar icons
  • 45 |
46 |
47 |
48 | 49 | 50 |
    51 |
  • Improved thumbnailing inside Flatpak
  • 52 |
  • Fixed drag and drop (again, hopefully finally)
  • 53 |
  • Ported to new widgets
  • 54 |
55 |
56 |
57 | 58 | 59 |
    60 |
  • Changed the app ID
  • 61 |
  • Fixed dragging and dropping to Firefox
  • 62 |
  • Other smaller fixes
  • 63 |
64 |
65 |
66 | 67 | 68 |
    69 |
  • Added the ability to run executable files
  • 70 |
  • Updated to new adaptive dialogs
  • 71 |
  • Numerous fixes and UX changes
  • 72 |
73 |
74 |
75 | 76 | 77 |
    78 |
  • Improved dragging and dropping
  • 79 |
  • Added banners to special locations
  • 80 |
  • Small fixes
  • 81 |
82 |
83 |
84 | 85 | 86 |
    87 |
  • The app now shows additional tags under tagged items
  • 88 |
  • Small fixes
  • 89 |
90 |
91 |
92 | 93 | 94 |

Initial beta release

95 |
96 |
97 |
98 |
99 | -------------------------------------------------------------------------------- /data/screenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/hyperplane/a63e21d6691fe96607208dc7ae23f7107f959cc5/data/screenshots/1.png -------------------------------------------------------------------------------- /hyperplane.doap: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | Hyperplane 9 | Organize your files freely 10 | 11 | Hyperplane is a non-hierarchical file manager. It allows you to organize your files by placing them in multiple categories at once while still being able to access these categories from traditional file managers. 12 | 13 | 14 | 15 | 16 | 17 | 18 | Python 19 | GTK 4 20 | Libadwaita 21 | 22 | 23 | 24 | kramo 25 | 26 | 27 | 28 | 29 | kra-mo 30 | 31 | 32 | 33 | 34 | 35 | kramo 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /hyperplane/__builtins__.pyi: -------------------------------------------------------------------------------- 1 | """Additional builtins mainly for IntelliSense.""" 2 | 3 | def _(_msg: str, /) -> str: ... 4 | -------------------------------------------------------------------------------- /hyperplane/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/hyperplane/a63e21d6691fe96607208dc7ae23f7107f959cc5/hyperplane/__init__.py -------------------------------------------------------------------------------- /hyperplane/assets/folder-closed.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hyperplane/assets/folder-open.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hyperplane/assets/welcome-folders.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hyperplane/assets/welcome-tags.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hyperplane/devel/timer.py: -------------------------------------------------------------------------------- 1 | # timer.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Print the time it took to execute a function.""" 21 | from time import perf_counter 22 | from typing import Callable 23 | 24 | 25 | def timer(func: Callable) -> Callable: 26 | """Print the time it took to execute a function.""" 27 | 28 | def wrapper(*args, **kwargs): 29 | start = perf_counter() 30 | retval = func(*args, **kwargs) 31 | end = perf_counter() 32 | 33 | print(round(end - start, 3)) 34 | 35 | return retval 36 | 37 | return wrapper 38 | -------------------------------------------------------------------------------- /hyperplane/editable_row.py: -------------------------------------------------------------------------------- 1 | # editable_row.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A row in the sidebar representing a tag.""" 21 | from typing import Optional 22 | 23 | from gi.repository import GLib, GObject, Gtk, Pango 24 | 25 | from hyperplane import shared 26 | from hyperplane.hover_page_opener import HypHoverPageOpener 27 | 28 | 29 | class HypEditableRow(Gtk.ListBoxRow, HypHoverPageOpener): 30 | """A row in the sidebar representing a tag.""" 31 | 32 | __gtype_name__ = "HypEditableRow" 33 | 34 | _identifier: str 35 | _editable: bool 36 | _active: bool 37 | 38 | # This is built in Python, because 39 | # TypeError: Inheritance from classes with @Gtk.Template decorators is not allowed at this time 40 | 41 | # HACK: *slaps roof of class* this baby can fit so mcuh spaghetti in her 42 | 43 | def __init__(self, identifier: Optional[str] = None, **kwargs) -> None: 44 | super().__init__(**kwargs) 45 | HypHoverPageOpener.__init__(self) 46 | 47 | self.image = Gtk.Image(opacity=0.8) 48 | self.label = Gtk.Label(ellipsize=Pango.EllipsizeMode.END) 49 | 50 | self.box = Gtk.Box(spacing=12, margin_start=6) 51 | self.box.append(self.image) 52 | self.box.append(self.label) 53 | 54 | self.check = Gtk.CheckButton(active=True) 55 | self.check.add_css_class("sidebar-check-button") 56 | self.check_revealer = Gtk.Revealer( 57 | child=self.check, 58 | halign=Gtk.Align.END, 59 | hexpand=True, 60 | visible=False, 61 | transition_type=Gtk.RevealerTransitionType.SLIDE_LEFT, 62 | ) 63 | 64 | self.box.append(self.check_revealer) 65 | 66 | self.set_child(self.box) 67 | 68 | self.editable = True 69 | self.identifier = identifier 70 | 71 | @GObject.Property(type=str) 72 | def identifier(self) -> str: 73 | """The identifier for the row used in dconf.""" 74 | return self._identifier 75 | 76 | @identifier.setter 77 | def set_identifier(self, identifier: str) -> None: 78 | if not identifier: 79 | return 80 | 81 | self._identifier = identifier 82 | 83 | self.set_active() 84 | 85 | @GObject.Property(type=bool, default=True) 86 | def editable(self) -> str: 87 | """Whether the row is actually editable.""" 88 | return self._editable 89 | 90 | @editable.setter 91 | def set_editable(self, editable: bool) -> None: 92 | self._editable = editable 93 | 94 | @GObject.Property(type=str) 95 | def icon_name(self) -> str: 96 | """The icon name for self.""" 97 | return self.image.get_icon_name() 98 | 99 | @icon_name.setter 100 | def set_icon_name(self, icon_name: str) -> None: 101 | self.image.set_from_icon_name(icon_name) 102 | 103 | @GObject.Property(type=str) 104 | def title(self) -> str: 105 | """The title for self.""" 106 | self.label.get_label() 107 | 108 | @title.setter 109 | def set_title(self, title: str) -> None: 110 | self.label.set_label(title) 111 | 112 | def start_edit(self) -> None: 113 | """Reveals the check button for editing.""" 114 | self.set_visible(True) 115 | 116 | self.check.set_sensitive(self.editable) 117 | self.check_revealer.set_visible(True) 118 | self.check_revealer.set_reveal_child(True) 119 | 120 | def end_edit(self) -> None: 121 | """Saves the edits and updates the row accordingly.""" 122 | self.check_revealer.set_reveal_child(False) 123 | GLib.timeout_add( 124 | self.check_revealer.get_transition_duration(), 125 | self.check_revealer.set_visible, 126 | False, 127 | ) 128 | 129 | var = shared.schema.get_value("hidden-locations") 130 | 131 | if self.check.get_active(): 132 | children = [] 133 | write = False 134 | 135 | for index in range(var.n_children()): 136 | child = var.get_child_value(index) 137 | if not child.get_string() == self.identifier: 138 | children.append(child) 139 | continue 140 | 141 | write = True 142 | 143 | if not write: 144 | return 145 | 146 | var = GLib.Variant.new_array(GLib.VariantType.new("s"), children) 147 | else: 148 | self.set_visible(False) 149 | children = [] 150 | for index in range(var.n_children()): 151 | child = var.get_child_value(index) 152 | if child.get_string() == self.identifier: 153 | continue 154 | children.append(child) 155 | 156 | children.append(GLib.Variant.new_string(self.identifier)) 157 | var = GLib.Variant.new_array(GLib.VariantType.new("s"), children) 158 | 159 | shared.schema.set_value("hidden-locations", var) 160 | 161 | def set_active(self) -> None: 162 | """ 163 | Sets the checkmark to active/inactive based on dconf. 164 | 165 | This should only be called extenally 166 | if the sidebar has been changed from a different window. 167 | """ 168 | 169 | var = shared.schema.get_value("hidden-locations") 170 | for index in range(var.n_children()): 171 | if var.get_child_value(index).get_string() == self.identifier: 172 | self.check.set_active(False) 173 | break 174 | else: 175 | self.check.set_active(True) 176 | 177 | # If we are not in edit mode 178 | if not self.check_revealer.get_reveal_child(): 179 | self.set_visible(self.check.get_active()) 180 | -------------------------------------------------------------------------------- /hyperplane/file_manager_dbus.py: -------------------------------------------------------------------------------- 1 | # file_manager_dbus.py 2 | # 3 | # Copyright 2023 Benedek Dévényi 4 | # Copyright 2023-2024 kramo 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | 22 | """https://www.freedesktop.org/wiki/Specifications/file-manager-interface/""" 23 | from __future__ import annotations 24 | 25 | from gi.repository import Gio, GLib 26 | 27 | from hyperplane import shared 28 | from hyperplane.properties import HypPropertiesDialog 29 | 30 | INTERFACE_DESC = """ 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | """ 53 | 54 | 55 | NAME = "org.freedesktop.FileManager1" 56 | PATH = "/org/freedesktop/FileManager1" 57 | 58 | 59 | class FileManagerDBusServer: 60 | """https://www.freedesktop.org/wiki/Specifications/file-manager-interface/""" 61 | 62 | def __init__(self) -> None: 63 | self._name_id = Gio.bus_own_name( 64 | Gio.BusType.SESSION, 65 | NAME, 66 | Gio.BusNameOwnerFlags.NONE, 67 | self.__on_bus_acquired, 68 | None, 69 | None, 70 | ) 71 | 72 | def __del__(self): 73 | Gio.bus_unown_name(self._name_id) 74 | 75 | def __on_bus_acquired(self, connection: Gio.DBusConnection, _): 76 | for interface in Gio.DBusNodeInfo.new_for_xml(INTERFACE_DESC).interfaces: 77 | try: 78 | connection.register_object( 79 | object_path=PATH, 80 | interface_info=interface, 81 | method_call_closure=self.__on_method_call, 82 | ) 83 | except Exception: # pylint: disable=broad-exception-caught 84 | # Another instance already exported at this path 85 | return 86 | 87 | def __on_method_call( 88 | self, 89 | _connection: Gio.DBusConnection, 90 | _sender: str, 91 | _object_path: str, 92 | interface_name: str, 93 | method_name: str, 94 | parameters: GLib.Variant, 95 | invocation: Gio.DBusMethodInvocation, 96 | ) -> None: 97 | args = tuple(parameters.unpack()) 98 | 99 | match method_name: 100 | case "ShowFolders": 101 | gfiles = tuple(Gio.File.new_for_uri(uri) for uri in args[0]) 102 | 103 | for gfile in gfiles: 104 | shared.app.do_activate(gfile) 105 | 106 | case "ShowItems": 107 | gfiles = tuple(Gio.File.new_for_uri(uri) for uri in args[0]) 108 | 109 | for gfile in gfiles: 110 | if not (parent := gfile.get_parent()): 111 | continue 112 | 113 | win = shared.app.do_activate(parent) 114 | win.select_uri = gfile.get_uri() 115 | 116 | case "ShowItemProperties": 117 | gfiles = tuple(Gio.File.new_for_uri(uri) for uri in args[0]) 118 | 119 | for gfile in gfiles: 120 | if not (parent := gfile.get_parent()): 121 | continue 122 | 123 | win = shared.app.do_activate(parent) 124 | 125 | properties = HypPropertiesDialog(gfile) 126 | properties.present(win) 127 | 128 | case "Introspect": 129 | variant = GLib.Variant("(s)", (INTERFACE_DESC,)) 130 | invocation.return_value(variant) 131 | return 132 | 133 | case _: 134 | invocation.return_dbus_error( 135 | f"{interface_name}.Error.NotSupported", 136 | "Unsupported property", 137 | ) 138 | 139 | invocation.return_value(None) 140 | -------------------------------------------------------------------------------- /hyperplane/file_properties.py: -------------------------------------------------------------------------------- 1 | # file_properties.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Miscellaneous variables for determining file properties.""" 21 | 22 | from gi.repository import Gio, GLib 23 | 24 | DOT_IS_NOT_EXTENSION = { 25 | "application/x-sharedlib", 26 | "application/x-executable", 27 | "application/x-pie-executable", 28 | "inode/symlink", 29 | } 30 | 31 | 32 | # This is so nonexistent URIs never match 33 | class _Fake: 34 | def __eq__(self, o: object): 35 | return False 36 | 37 | 38 | class SpecialUris: 39 | """URIs that point to special directories.""" 40 | 41 | if templates_dir := GLib.get_user_special_dir( 42 | GLib.UserDirectory.DIRECTORY_TEMPLATES 43 | ): 44 | templates_uri = Gio.File.new_for_path(templates_dir).get_uri() 45 | else: 46 | templates_uri = _Fake() 47 | 48 | if public_dir := GLib.get_user_special_dir( 49 | GLib.UserDirectory.DIRECTORY_PUBLIC_SHARE 50 | ): 51 | public_uri = Gio.File.new_for_path(public_dir).get_uri() 52 | else: 53 | public_uri = _Fake() 54 | 55 | if downloads_dir := GLib.get_user_special_dir( 56 | GLib.UserDirectory.DIRECTORY_DOWNLOAD 57 | ): 58 | downloads_uri = Gio.File.new_for_path(downloads_dir).get_uri() 59 | else: 60 | downloads_uri = _Fake() 61 | 62 | trash_uri = "trash:///" 63 | recent_uri = "recent:///" 64 | -------------------------------------------------------------------------------- /hyperplane/gtk/guide.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypGuide: Adw.Dialog { 5 | title: _("Welcome to Hyperplane"); 6 | content-height: 520; 7 | content-width: 700; 8 | 9 | WindowHandle { 10 | Adw.ToolbarView { 11 | [top] 12 | Adw.HeaderBar { 13 | [title] 14 | Adw.CarouselIndicatorLines { 15 | carousel: carousel; 16 | } 17 | } 18 | 19 | Adw.Carousel carousel { 20 | interactive: false; 21 | 22 | Adw.StatusPage page_1 { 23 | title: _("Welcome to Hyperplane"); 24 | description: _("This guide will help you learn its features"); 25 | 26 | Button button_1 { 27 | halign: center; 28 | label: _("Start"); 29 | 30 | styles [ 31 | "pill", 32 | "suggested-action", 33 | ] 34 | 35 | clicked => $_next_page(); 36 | } 37 | } 38 | 39 | ScrolledWindow { 40 | hexpand: true; 41 | 42 | Box { 43 | orientation: vertical; 44 | valign: center; 45 | 46 | Picture tags_picture { 47 | content-fit: scale_down; 48 | } 49 | 50 | Adw.StatusPage { 51 | title: _("Organize by Categories"); 52 | description: _("Categories allow you to organize your files and folders however you like,\nan item can be in any category and you can access your categories in any order"); 53 | 54 | Button button_2 { 55 | halign: center; 56 | label: _("Next"); 57 | 58 | styles [ 59 | "pill", 60 | "suggested-action", 61 | ] 62 | 63 | clicked => $_next_page(); 64 | } 65 | } 66 | } 67 | } 68 | 69 | ScrolledWindow { 70 | hexpand: true; 71 | 72 | Box { 73 | orientation: vertical; 74 | valign: center; 75 | 76 | Picture folders_picture { 77 | content-fit: scale_down; 78 | } 79 | 80 | Adw.StatusPage { 81 | title: _("You Own Your Files"); 82 | description: _("Categories are simply stored as folders on disk\nso you can easily access them even outside Hyperplane"); 83 | 84 | Button button_3 { 85 | halign: center; 86 | label: _("Next"); 87 | 88 | styles [ 89 | "pill", 90 | "suggested-action", 91 | ] 92 | 93 | clicked => $_next_page(); 94 | } 95 | } 96 | } 97 | } 98 | 99 | ScrolledWindow { 100 | hexpand: true; 101 | 102 | Box { 103 | orientation: vertical; 104 | valign: center; 105 | 106 | Adw.Clamp { 107 | maximum-size: 120; 108 | 109 | Overlay { 110 | [overlay] 111 | Image { 112 | margin-top: 6; 113 | icon-size: large; 114 | icon-name: "preferences-system-privacy-symbolic"; 115 | 116 | styles [ 117 | "blue-icon-light-only" 118 | ] 119 | } 120 | 121 | Picture folder_picture { 122 | content-fit: fill; 123 | halign: center; 124 | valign: center; 125 | 126 | styles [ 127 | "item-thumbnail", 128 | "dark-blue-background", 129 | ] 130 | } 131 | } 132 | } 133 | 134 | Adw.StatusPage { 135 | title: _("Transparent"); 136 | description: _("Hyperplane will not move files automatically when editing categories\nso all file operations are explicit"); 137 | 138 | Button button_4 { 139 | halign: center; 140 | label: _("Show Hyperplane"); 141 | 142 | styles [ 143 | "pill", 144 | "suggested-action", 145 | ] 146 | 147 | action-name: "window.close"; 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /hyperplane/gtk/help-overlay.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | ShortcutsWindow help_overlay { 4 | modal: true; 5 | // TODO: Figure out a way to make page action shortcuts work without specifying accels manually 6 | ShortcutsSection { 7 | section-name: "shortcuts"; 8 | max-height: 10; 9 | 10 | ShortcutsGroup { 11 | title: _("Navigation"); 12 | 13 | ShortcutsShortcut { 14 | title: _("Go Back"); 15 | accelerator: "Left"; 16 | } 17 | 18 | ShortcutsShortcut { 19 | title: _("Go Forward"); 20 | accelerator: "Right"; 21 | } 22 | 23 | ShortcutsShortcut { 24 | title: _("Go to Home Folder"); 25 | action-name: "win.home"; 26 | } 27 | 28 | ShortcutsShortcut { 29 | title: _("Toggle Location Bar"); 30 | action-name: "win.toggle-path-entry"; 31 | } 32 | } 33 | 34 | ShortcutsGroup { 35 | title: _("Editing"); 36 | 37 | ShortcutsShortcut { 38 | title: _("Create Folder"); 39 | accelerator: "n"; 40 | } 41 | 42 | ShortcutsShortcut { 43 | title: _("Create File"); 44 | accelerator: "n"; 45 | } 46 | 47 | ShortcutsShortcut { 48 | title: _("Rename"); 49 | action-name: "win.rename"; 50 | } 51 | 52 | ShortcutsShortcut { 53 | title: _("Move to Trash"); 54 | accelerator: "Delete"; 55 | } 56 | 57 | ShortcutsShortcut { 58 | title: _("Cut"); 59 | accelerator: "x"; 60 | } 61 | 62 | ShortcutsShortcut { 63 | title: _("Copy"); 64 | accelerator: "c"; 65 | } 66 | 67 | ShortcutsShortcut { 68 | title: _("Paste"); 69 | accelerator: "v"; 70 | } 71 | 72 | ShortcutsShortcut { 73 | title: _("Select All"); 74 | accelerator: "a"; 75 | } 76 | 77 | ShortcutsShortcut { 78 | title: _("Show Item Properties"); 79 | action-name: "win.properties"; 80 | } 81 | } 82 | 83 | ShortcutsGroup { 84 | title: _("View"); 85 | 86 | ShortcutsShortcut { 87 | title: _("Zoom In"); 88 | accelerator: "plus"; 89 | } 90 | 91 | ShortcutsShortcut { 92 | title: _("Zoom Out"); 93 | accelerator: "minus"; 94 | } 95 | 96 | ShortcutsShortcut { 97 | title: _("Reset Zoom"); 98 | accelerator: "0"; 99 | } 100 | 101 | ShortcutsShortcut { 102 | title: _("Refresh View"); 103 | action-name: "win.reload"; 104 | } 105 | 106 | ShortcutsShortcut { 107 | title: _("Show/Hide Hidden Files"); 108 | action-name: "app.show-hidden"; 109 | } 110 | 111 | ShortcutsShortcut { 112 | title: _("Show Main Menu"); 113 | accelerator: "F10"; 114 | } 115 | 116 | ShortcutsShortcut { 117 | title: _("List View"); 118 | action-name: "win.list-view"; 119 | } 120 | 121 | ShortcutsShortcut { 122 | title: _("Grid View"); 123 | action-name: "win.grid-view"; 124 | } 125 | } 126 | 127 | ShortcutsGroup { 128 | title: _("Tabs"); 129 | 130 | ShortcutsShortcut { 131 | title: _("New Tab"); 132 | action-name: "win.new-tab"; 133 | } 134 | 135 | ShortcutsShortcut { 136 | title: _("Go to Previous Tab"); 137 | accelerator: "Page_Up"; 138 | } 139 | 140 | ShortcutsShortcut { 141 | title: _("Go to Next Tab"); 142 | accelerator: "Page_Down"; 143 | } 144 | 145 | ShortcutsShortcut { 146 | title: _("Open Tab"); 147 | accelerator: "0...8"; 148 | } 149 | 150 | ShortcutsShortcut { 151 | title: _("Move Tab Left"); 152 | accelerator: "Page_Up"; 153 | } 154 | 155 | ShortcutsShortcut { 156 | title: _("Move Tab Right"); 157 | accelerator: "Page_Down"; 158 | } 159 | 160 | ShortcutsShortcut { 161 | title: _("View Open Tabs"); 162 | action-name: "win.tab-overview"; 163 | } 164 | 165 | ShortcutsShortcut { 166 | title: _("Restore Tab"); 167 | action-name: "win.reopen-tab"; 168 | } 169 | } 170 | 171 | ShortcutsGroup { 172 | title: _("General"); 173 | 174 | ShortcutsShortcut { 175 | title: _("New Window"); 176 | action-name: "win.new-window"; 177 | } 178 | 179 | ShortcutsShortcut { 180 | title: _("Close Window or Tab"); 181 | action-name: "win.close"; 182 | } 183 | 184 | ShortcutsShortcut { 185 | title: _("Quit"); 186 | action-name: "app.quit"; 187 | } 188 | 189 | ShortcutsShortcut { 190 | title: _("Search"); 191 | action-name: "win.search"; 192 | } 193 | 194 | ShortcutsShortcut { 195 | title: _("Preferences"); 196 | action-name: "app.preferences"; 197 | } 198 | 199 | ShortcutsShortcut { 200 | title: _("Keyboard Shortcuts"); 201 | action-name: "win.show-help-overlay"; 202 | } 203 | 204 | ShortcutsShortcut { 205 | title: _("Guide"); 206 | action-name: "app.show-guide"; 207 | } 208 | 209 | ShortcutsShortcut { 210 | title: _("Undo"); 211 | accelerator: "z"; 212 | } 213 | } 214 | 215 | ShortcutsGroup { 216 | title: _("Opening"); 217 | 218 | ShortcutsShortcut { 219 | title: _("Open"); 220 | accelerator: "Return o"; 221 | } 222 | 223 | ShortcutsShortcut { 224 | title: _("Open in New Tab"); 225 | accelerator: "Return"; 226 | } 227 | 228 | ShortcutsShortcut { 229 | title: _("Open in New Window"); 230 | accelerator: "Return"; 231 | } 232 | } 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /hyperplane/gtk/item.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypItem: Adw.Bin { 5 | Box box { 6 | Overlay overlay { 7 | overflow: hidden; 8 | halign: center; 9 | 10 | [overlay] 11 | Image circular_icon { 12 | gicon: bind template.gicon; 13 | 14 | styles [ 15 | "circular-icon" 16 | ] 17 | } 18 | 19 | Overlay thumbnail_overlay { 20 | overflow: hidden; 21 | halign: center; 22 | 23 | [overlay] 24 | Box dir_thumbnails { 25 | halign: start; 26 | valign: start; 27 | margin-end: 6; 28 | 29 | Overlay dir_thumbnail_1 { 30 | overflow: hidden; 31 | 32 | [overlay] 33 | Picture dir_picture_1 { 34 | visible: false; 35 | content-fit: cover; 36 | } 37 | 38 | Image { 39 | visible: bind dir_picture_1.visible inverted; 40 | } 41 | 42 | styles [ 43 | "small-thumbnail", 44 | ] 45 | } 46 | 47 | Overlay dir_thumbnail_2 { 48 | overflow: hidden; 49 | 50 | [overlay] 51 | Picture dir_picture_2 { 52 | visible: false; 53 | content-fit: cover; 54 | } 55 | 56 | Image { 57 | visible: bind dir_picture_2.visible inverted; 58 | } 59 | 60 | styles [ 61 | "small-thumbnail", 62 | ] 63 | } 64 | 65 | Overlay dir_thumbnail_3 { 66 | overflow: hidden; 67 | 68 | [overlay] 69 | Picture dir_picture_3 { 70 | visible: false; 71 | content-fit: cover; 72 | } 73 | 74 | Image { 75 | visible: bind dir_picture_3.visible inverted; 76 | } 77 | 78 | styles [ 79 | "small-thumbnail", 80 | ] 81 | } 82 | } 83 | 84 | [overlay] 85 | Picture picture { 86 | content-fit: cover; 87 | paintable: bind template.thumbnail-paintable; 88 | 89 | styles [ 90 | "thumbnail-picture", 91 | ] 92 | } 93 | 94 | [overlay] 95 | Box play_button { 96 | halign: start; 97 | valign: start; 98 | visible: false; 99 | 100 | Image play_button_icon { 101 | icon-name: "media-playback-start-symbolic"; 102 | } 103 | 104 | styles [ 105 | "circular-box", 106 | "play-button", 107 | ] 108 | } 109 | 110 | [overlay] 111 | Label extension_label { 112 | valign: end; 113 | halign: end; 114 | margin-bottom: 5; 115 | margin-end: 5; 116 | margin-start: 5; 117 | ellipsize: end; 118 | label: bind template.extension; 119 | 120 | styles [ 121 | "file-extension", 122 | ] 123 | } 124 | 125 | Image icon { 126 | gicon: bind template.gicon; 127 | visible: bind picture.visible inverted; 128 | } 129 | 130 | styles [ 131 | "item-thumbnail", 132 | ] 133 | } 134 | } 135 | 136 | Box labels_box { 137 | orientation: vertical; 138 | valign: center; 139 | 140 | Label label { 141 | natural-wrap-mode: word; 142 | wrap-mode: word_char; 143 | ellipsize: middle; 144 | label: bind template.display-name; 145 | } 146 | 147 | Label tags_label { 148 | ellipsize: end; 149 | label: bind template.additional-tags; 150 | visible: false; 151 | 152 | styles [ 153 | "dim-label", 154 | "caption" 155 | ] 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /hyperplane/gtk/items-page.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypItemsPage: Adw.NavigationPage { 5 | ScrolledWindow scrolled_window {} 6 | } 7 | 8 | GridView grid_view { 9 | enable-rubberband: true; 10 | 11 | styles [ 12 | "hyperplane-grid-view" 13 | ] 14 | } 15 | 16 | ColumnView column_view { 17 | enable-rubberband: true; 18 | reorderable: false; 19 | 20 | styles [ 21 | "hyperplane-column-view" 22 | ] 23 | } 24 | 25 | Adw.StatusPage empty_folder { 26 | title: _("Folder Is Empty"); 27 | icon-name: "folder-symbolic"; 28 | } 29 | 30 | Adw.StatusPage no_downloads { 31 | title: _("No Downloads"); 32 | icon-name: "folder-download-symbolic"; 33 | } 34 | 35 | Adw.StatusPage no_matching_items { 36 | title: _("No Matching Items"); 37 | icon-name: "tag-outline-symbolic"; 38 | } 39 | 40 | Adw.StatusPage empty_trash { 41 | title: _("Trash Is Empty"); 42 | icon-name: "user-trash-symbolic"; 43 | } 44 | 45 | Adw.StatusPage no_recents { 46 | title: _("No Recent Files"); 47 | icon-name: "document-open-recent-symbolic"; 48 | } 49 | 50 | Adw.StatusPage no_results { 51 | title: _("No Results Found"); 52 | icon-name: "system-search-symbolic"; 53 | } 54 | 55 | Viewport loading { 56 | Spinner { 57 | hexpand: true; 58 | halign: center; 59 | width-request: 32; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /hyperplane/gtk/new-file-dialog.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypNewFileDialog: Adw.Dialog { 5 | title: _("New File"); 6 | content-width: 450; 7 | 8 | Adw.NavigationView navigation_view { 9 | Adw.NavigationPage { 10 | title: _("New File"); 11 | 12 | Adw.ToolbarView toolbar_view { 13 | [top] 14 | Adw.HeaderBar {} 15 | 16 | Adw.StatusPage { 17 | title: _("No Templates"); 18 | description: _("Place files in your Templates folder for them to show up here"); 19 | icon-name: "folder-templates-symbolic"; 20 | 21 | Button templates_folder_button { 22 | halign: center; 23 | label: _("Open Templates"); 24 | 25 | styles [ 26 | "pill", 27 | "suggested-action", 28 | ] 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | Adw.PreferencesPage files_page { 37 | description: _("Place files in your Templates folder for them to show up here"); 38 | 39 | Adw.PreferencesGroup files_group {} 40 | } 41 | 42 | Adw.NavigationPage name_page { 43 | title: _("Choose File Name"); 44 | 45 | Adw.ToolbarView { 46 | [top] 47 | Adw.HeaderBar {} 48 | 49 | Adw.PreferencesPage { 50 | valign: center; 51 | 52 | Adw.PreferencesGroup { 53 | Adw.Bin icon_bin { 54 | halign: center; 55 | } 56 | } 57 | 58 | Adw.PreferencesGroup { 59 | TextView name_text_view { 60 | justification: center; 61 | hexpand: true; 62 | 63 | /* HACK: these are to address a bug with the title-2 style calss + the text view */ 64 | top-margin: 6; 65 | bottom-margin: 6; 66 | 67 | styles [ 68 | "title-2", 69 | "bg-text-view", 70 | ] 71 | } 72 | 73 | Revealer warning_revealer { 74 | transition-type: swing_down; 75 | margin-top: 6; 76 | 77 | Label warning_revealer_label { 78 | justify: center; 79 | wrap: true; 80 | } 81 | } 82 | 83 | Button create_button { 84 | margin-top: 30; 85 | margin-bottom: 48; 86 | halign: center; 87 | label: _("Create File"); 88 | 89 | styles [ 90 | "pill", 91 | "suggested-action", 92 | ] 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /hyperplane/gtk/path-bar.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $HypPathBar: ScrolledWindow { 4 | vscrollbar-policy: never; 5 | hscrollbar-policy: external; 6 | 7 | Viewport viewport { 8 | Box segments_box {} 9 | } 10 | 11 | styles [ 12 | "undershoot-start", 13 | "undershoot-end", 14 | "path-bar", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /hyperplane/gtk/path-entry.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | 3 | template $HypPathEntry: Entry { 4 | hexpand: true; 5 | 6 | ShortcutController { 7 | Shortcut { 8 | trigger: "Escape"; 9 | action: "action(win.hide-path-entry)"; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /hyperplane/gtk/path-segment.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypPathSegment: Revealer { 5 | Button button { 6 | Adw.ButtonContent button_content {} 7 | 8 | styles [ 9 | "flat", 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /hyperplane/gtk/preferences.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypPreferencesDialog: Adw.PreferencesDialog { 5 | Adw.PreferencesPage { 6 | Adw.PreferencesGroup { 7 | title: _("General"); 8 | 9 | Adw.SwitchRow folders_switch_row { 10 | title: _("Sort Folders Before Files"); 11 | } 12 | 13 | Adw.SwitchRow single_click_open_switch_row { 14 | title: _("Activate Items With a Single Click"); 15 | } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /hyperplane/gtk/style-dark.css: -------------------------------------------------------------------------------- 1 | .thumbnail-picture { 2 | filter: initial; 3 | } 4 | 5 | navigation-view > shadow { 6 | background: radial-gradient(farthest-side at right, rgba(0,0,0,.15) 0%, rgba(0,0,0,0) 100%); 7 | } 8 | 9 | .blue-icon { color: @blue_1; } 10 | .blue-background { background: alpha(@blue_5, 0.5); } 11 | .blue-solid-background { background: shade(@blue_5, 0.9); } 12 | .blue-extension { background: @blue_1; color: @dark_5; } 13 | 14 | .green-icon { color: @green_1; } 15 | .green-background { background: alpha(@green_5, 0.5); } 16 | .green-solid-background { background: shade(@green_5, 0.9); } 17 | .green-extension { background: @green_1; color: @dark_5; } 18 | 19 | .yellow-icon { color: @yellow_1; } 20 | .yellow-background { background: alpha(@yellow_5, 0.5); } 21 | .yellow-solid-background { background: shade(@yellow_5, 0.9); } 22 | .yellow-extension { background: @yellow_1; color: @dark_5; } 23 | 24 | .orange-icon { color: @orange_1; } 25 | .orange-background { background: alpha(@orange_5, 0.5); } 26 | .orange-solid-background { background: shade(@orange_5, 0.9); } 27 | .orange-extension { background: @orange_1; color: @dark_5; } 28 | 29 | .red-icon { color: @red_1; } 30 | .red-background { background: alpha(@red_5, 0.5); } 31 | .red-solid-background { background: shade(@red_5, 0.9); } 32 | .red-extension { background: @red_1; color: @dark_5; } 33 | 34 | .purple-icon { color: @purple_1; } 35 | .purple-background { background: alpha(@purple_5, 0.5); } 36 | .purple-solid-background { background: shade(@purple_5, 0.9); } 37 | .purple-extension { background: @purple_1; color: @dark_5; } 38 | 39 | .gray-icon { color: @light_3; } 40 | .gray-background { background: alpha(@dark_2, 0.5); } 41 | .gray-solid-background { background: shade(@dark_2, 0.9); } 42 | .gray-extension { background: @light_4; color: @dark_5; } 43 | -------------------------------------------------------------------------------- /hyperplane/gtk/style.css: -------------------------------------------------------------------------------- 1 | /* For flat header bars that don't look weird */ 2 | .flat-navigation-view > dimming { 3 | opacity: 0; 4 | } 5 | 6 | .flat-navigation-view > shadow { 7 | background: radial-gradient(farthest-side at right, rgba(0,0,0,.08) 0%, rgba(0,0,0,0) 100%); 8 | } 9 | 10 | .item-thumbnail { 11 | border-radius: 9px; 12 | } 13 | 14 | .small-thumbnail { 15 | border-radius: 6px; 16 | } 17 | 18 | .hyperplane-grid-view { 19 | padding: 15px; 20 | border-spacing: 12px; 21 | } 22 | 23 | /* 24 | Padding hack from 25 | https://github.com/GNOME/nautilus/blob/e2f7070feb19c7ec9d0f43d9721f446a82072281/src/resources/style.css#L175 26 | */ 27 | .hyperplane-column-view { 28 | padding: 0px 12px; 29 | } 30 | .hyperplane-column-view > listview { 31 | padding: 6px 0px 12px 0px; 32 | border-spacing: 9px; 33 | margin-left: -12px; 34 | margin-right: -12px; 35 | } 36 | .hyperplane-column-view > listview > row { 37 | border-radius: 6px; 38 | margin-left: 12px; 39 | margin-right: 12px; 40 | } 41 | 42 | rubberband { 43 | border-radius: 6px; 44 | } 45 | 46 | /* Stolen from Nautilus with some modifications */ 47 | .path-bar { 48 | background-color: alpha(currentColor, 0.1); 49 | border-radius: 6px; 50 | } 51 | .path-bar > undershoot.left { 52 | background: linear-gradient(to right, alpha(@headerbar_shade_color, 0.5) 6px, alpha(@headerbar_shade_color, 0) 32px); 53 | border-left: solid 1px @borders; 54 | border-radius: 6px 0px 0px 6px; 55 | } 56 | .path-bar > undershoot.right { 57 | background: linear-gradient(to left, alpha(@headerbar_shade_color, 0.5) 6px, alpha(@headerbar_shade_color, 0) 32px); 58 | border-right: solid 1px @borders; 59 | border-radius: 0px 6px 6px 0px; 60 | } 61 | .path-bar > viewport > box > revealer > button { 62 | padding: 2px 9px; 63 | margin: 3px; 64 | border-radius: 4px; 65 | } 66 | 67 | .inactive-segment { 68 | opacity: 0.5; 69 | transition-duration: 0.2s; 70 | } 71 | 72 | .inactive-segment:hover { 73 | opacity: 1; 74 | } 75 | 76 | /* Darken thumbnails so pure white images don't look weird */ 77 | .thumbnail-picture { 78 | filter: brightness(.97); 79 | } 80 | 81 | .file-extension { 82 | padding: 2px 5px; 83 | font-weight: 700; 84 | border-radius: 4px; 85 | font-size: small; 86 | } 87 | 88 | .cut-item { 89 | filter: opacity(50%); 90 | } 91 | 92 | .sidebar-drop-target { 93 | border-bottom: @accent_bg_color 3px solid; 94 | } 95 | 96 | .sidebar-check-button > check { 97 | color: @sidebar_fg_color; 98 | background-color: @sidebar_shade_color; 99 | } 100 | 101 | .play-button { 102 | color: white; 103 | background-image: radial-gradient(circle closest-side, alpha(black, .1), transparent); 104 | } 105 | 106 | .circular-box { 107 | border-radius: 50%; 108 | } 109 | 110 | .circular-icon { 111 | border-radius: 50%; 112 | padding: 16px; 113 | } 114 | 115 | .sidebar-button { 116 | border-radius: 50%; 117 | padding: 2px; 118 | } 119 | 120 | /* Remove the top margin since the header bar provides enough already */ 121 | .properties-page > scrolledwindow > viewport > clamp > box { 122 | margin-top: 0; 123 | } 124 | 125 | .bg-text-view > text { 126 | background-color: @window_bg_color; 127 | } 128 | 129 | 130 | .white-background { background: @light_1; } 131 | .white-icon { color: @light_1; } 132 | 133 | .dark-blue-background { background: @blue_5; } 134 | .light-blue-background { background: @blue_3; } 135 | 136 | .blue-background { background: alpha(@blue_1, 0.5); } 137 | .blue-solid-background { background: shade(@blue_1, 1.1); } 138 | .blue-icon { color: @blue_5; } 139 | .blue-icon-light-only { color: @blue_5; } 140 | .blue-extension { background: @blue_5; color: @light_1; } 141 | .blue-extension-thumb { background: @blue_5; color: @light_1; } 142 | 143 | .green-background { background: alpha(@green_1, 0.5); } 144 | .green-solid-background { background: shade(@green_1, 1.1); } 145 | .green-icon { color: @green_5; } 146 | .green-icon-light-only { color: @green_5; } 147 | .green-extension { background: @green_5; color: @light_1; } 148 | .green-extension-thumb { background: @green_5; color: @light_1; } 149 | 150 | .yellow-background { background: alpha(@yellow_1, 0.5); } 151 | .yellow-solid-background { background: shade(@yellow_1, 1.1); } 152 | .yellow-icon { color: @yellow_5; } 153 | .yellow-icon-light-only { color: @yellow_5; } 154 | .yellow-extension { background: @yellow_5; color: @light_1; } 155 | .yellow-extension-thumb { background: @yellow_5; color: @light_1; } 156 | 157 | .orange-background { background: alpha(@orange_1, 0.5); } 158 | .orange-solid-background { background: shade(@orange_1, 1.1); } 159 | .orange-icon { color: @orange_5; } 160 | .orange-icon-light-only { color: @orange_5; } 161 | .orange-extension { background: @orange_5; color: @light_1; } 162 | .orange-extension-thumb { background: @orange_5; color: @light_1; } 163 | 164 | .red-background { background: alpha(@red_1, 0.5); } 165 | .red-solid-background { background: shade(@red_1, 1.1); } 166 | .red-icon { color: @red_5; } 167 | .red-icon-light-only { color: @red_5; } 168 | .red-extension { background: @red_5; color: @light_1; } 169 | .red-extension-thumb { background: @red_5; color: @light_1; } 170 | 171 | .purple-background { background: alpha(@purple_1, 0.5); } 172 | .purple-solid-background { background: shade(@purple_1, 1.1); } 173 | .purple-icon { color: @purple_4; } 174 | .purple-icon-light-only { color: @purple_4; } 175 | .purple-extension { background: @purple_4; color: @light_1; } 176 | .purple-extension-thumb { background: @purple_4; color: @light_1; } 177 | 178 | .gray-background { background: alpha(@light_4, 0.5); } 179 | .gray-solid-background { background: shade(@light_4, 1.1); } 180 | .gray-icon { color: @dark_2; } 181 | .gray-icon-light-only { color: @dark_2; } 182 | .gray-extension { background: @dark_2; color: @light_1; } 183 | .gray-extension-thumb { background: @dark_2; color: @light_1; } 184 | -------------------------------------------------------------------------------- /hyperplane/gtk/volumes-box.blp: -------------------------------------------------------------------------------- 1 | using Gtk 4.0; 2 | using Adw 1; 3 | 4 | template $HypVolumesBox: Adw.Bin { 5 | Gtk.ListBox list_box { 6 | selection-mode: none; 7 | 8 | styles [ 9 | "navigation-sidebar", 10 | ] 11 | } 12 | } 13 | 14 | PopoverMenu right_click_menu { 15 | halign: start; 16 | menu-model: right_click; 17 | has-arrow: false; 18 | } 19 | 20 | menu right_click { 21 | section { 22 | item (_("Open"), "win.open-sidebar") 23 | item (_("Open in New Tab"), "win.open-new-tab-sidebar") 24 | item (_("Open in New Window"), "win.open-new-window-sidebar") 25 | } 26 | 27 | section { 28 | item (_("Properties"), "win.properties-sidebar") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /hyperplane/guide.py: -------------------------------------------------------------------------------- 1 | # guide.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A window showcasing the features of the app.""" 21 | from gi.repository import Adw, Gtk 22 | 23 | from hyperplane import shared 24 | 25 | 26 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/guide.ui") 27 | class HypGuide(Adw.Dialog): 28 | """A window showcasing the features of the app.""" 29 | 30 | __gtype_name__ = "HypGuide" 31 | 32 | carousel: Adw.Carousel = Gtk.Template.Child() 33 | page_1: Adw.StatusPage = Gtk.Template.Child() 34 | tags_picture: Gtk.Picture = Gtk.Template.Child() 35 | folders_picture: Gtk.Picture = Gtk.Template.Child() 36 | folder_picture: Gtk.Picture = Gtk.Template.Child() 37 | 38 | button_1: Gtk.Button = Gtk.Template.Child() 39 | button_2: Gtk.Button = Gtk.Template.Child() 40 | button_3: Gtk.Button = Gtk.Template.Child() 41 | button_4: Gtk.Button = Gtk.Template.Child() 42 | 43 | def __init__(self, **kwargs) -> None: 44 | super().__init__(**kwargs) 45 | 46 | self.page_1.set_icon_name(shared.APP_ID) 47 | self.tags_picture.set_resource(shared.PREFIX + "/assets/welcome-tags.svg") 48 | self.folders_picture.set_resource(shared.PREFIX + "/assets/welcome-folders.svg") 49 | self.folder_picture.set_paintable(shared.closed_folder_texture) 50 | 51 | @Gtk.Template.Callback() 52 | def _next_page(self, _widget: Gtk.Widget) -> None: 53 | self.carousel.scroll_to( 54 | self.carousel.get_nth_page(pos := self.carousel.get_position() + 1), True 55 | ) 56 | self.set_focus(getattr(self, f"button_{int(pos) + 1}")) 57 | -------------------------------------------------------------------------------- /hyperplane/hover_page_opener.py: -------------------------------------------------------------------------------- 1 | # hover_page_opener.py 2 | # 3 | # Copyright 2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """ 21 | `GtkWidget`s inheriting from this class will become targets for 22 | opening a page representing their items while a DnD operation is ongoing. 23 | """ 24 | from typing import Any, Iterable, Optional 25 | 26 | from gi.repository import Gio, GLib, Gtk 27 | 28 | 29 | class HypHoverPageOpener: 30 | """ 31 | `GtkWidget`s inheriting from this class will become targets for 32 | opening a page representing their items while a DnD operation is ongoing. 33 | 34 | They must have either a `gfile`, `tag` or `tags` attribute. 35 | 36 | The class should only be used by widgets inside a `HypWindow`. 37 | """ 38 | 39 | gfile: Optional[Gio.File] 40 | tag: Optional[str] 41 | tags: Optional[Iterable[str]] 42 | 43 | def __init__(self) -> None: 44 | self.can_open_page = True 45 | 46 | self.drop_controller_motion = Gtk.DropControllerMotion.new() 47 | self.drop_controller_motion.connect("enter", self.__dnd_motion_enter) 48 | Gtk.Widget.add_controller(self, self.drop_controller_motion) 49 | 50 | def __hover_open(self, *_args: Any) -> None: 51 | win = Gtk.Widget.get_root(self) 52 | 53 | if self.drop_controller_motion.contains_pointer() and self.can_open_page: 54 | win.new_page( 55 | getattr(self, "gfile", None), 56 | getattr(self, "tag", None), 57 | getattr(self, "tags", None), 58 | ) 59 | 60 | def __dnd_motion_enter(self, *_args: Any) -> None: 61 | if self.can_open_page: 62 | GLib.timeout_add(500, self.__hover_open) 63 | -------------------------------------------------------------------------------- /hyperplane/hyperplane.gresource.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ../data/icons/scalable/actions/tag-outline-symbolic.svg 5 | ../data/icons/scalable/actions/tag-outline-add-symbolic.svg 6 | 7 | 8 | gtk/guide.ui 9 | gtk/help-overlay.ui 10 | gtk/item.ui 11 | gtk/new-file-dialog.ui 12 | gtk/items-page.ui 13 | gtk/path-bar.ui 14 | gtk/path-entry.ui 15 | gtk/path-segment.ui 16 | gtk/preferences.ui 17 | gtk/volumes-box.ui 18 | gtk/window.ui 19 | gtk/style.css 20 | gtk/style-dark.css 21 | assets/folder-closed.svg 22 | assets/folder-open.svg 23 | assets/welcome-tags.svg 24 | assets/welcome-folders.svg 25 | ../data/@APP_ID@.metainfo.xml 26 | 27 | 28 | -------------------------------------------------------------------------------- /hyperplane/hyperplane.in: -------------------------------------------------------------------------------- 1 | #!@PYTHON@ 2 | 3 | # hyperplane.in 4 | # 5 | # Copyright 2023-2024 kramo 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | # 20 | # SPDX-License-Identifier: GPL-3.0-or-later 21 | 22 | import gettext 23 | import locale 24 | import os 25 | import signal 26 | import sys 27 | 28 | VERSION = "@VERSION@" 29 | PKGDATADIR = "@pkgdatadir@" 30 | LOCALEDIR = "@localedir@" 31 | 32 | sys.path.insert(1, PKGDATADIR) 33 | signal.signal(signal.SIGINT, signal.SIG_DFL) 34 | locale.bindtextdomain("hyperplane", LOCALEDIR) 35 | locale.textdomain("hyperplane") 36 | gettext.install("hyperplane", LOCALEDIR) 37 | 38 | if __name__ == "__main__": 39 | from gi.repository import Gio 40 | 41 | resource = Gio.Resource.load(os.path.join(PKGDATADIR, "hyperplane.gresource")) 42 | resource._register() # pylint: disable=protected-access 43 | 44 | from hyperplane import main 45 | 46 | sys.exit(main.main(VERSION)) 47 | -------------------------------------------------------------------------------- /hyperplane/item_filter.py: -------------------------------------------------------------------------------- 1 | # item_filter.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Main filter for HypItemsPage.""" 21 | from typing import Optional 22 | 23 | from gi.repository import Gio, GLib, Gtk 24 | 25 | from hyperplane import shared 26 | from hyperplane.utils.tags import path_represents_tags 27 | 28 | 29 | class HypItemFilter(Gtk.Filter): 30 | """Main filter for HypItemsPage.""" 31 | 32 | __gtype_name__ = "HypItemFilter" 33 | 34 | def __tag_filter(self, file_info: Gio.FileInfo) -> bool: 35 | if not shared.tags: 36 | return True 37 | 38 | if file_info.get_content_type() != "inode/directory": 39 | return True 40 | 41 | if not (path := file_info.get_attribute_object("standard::file").get_path()): 42 | return True 43 | 44 | if not path_represents_tags(path): 45 | return True 46 | 47 | return False 48 | 49 | def __search_filter(self, file_info: Gio.FileInfo) -> bool: 50 | if not shared.search: 51 | return True 52 | 53 | search = shared.search.lower() 54 | 55 | if search in file_info.get_display_name().lower(): 56 | return True 57 | 58 | return False 59 | 60 | def __hidden_filter(self, file_info: Gio.FileInfo) -> bool: 61 | if shared.show_hidden: 62 | return True 63 | 64 | # Always show trashed hidden files 65 | if file_info.get_deletion_date(): 66 | return True 67 | 68 | try: 69 | if file_info.get_is_hidden(): 70 | return False 71 | except GLib.Error: 72 | pass 73 | return True 74 | 75 | def do_match(self, file_info: Optional[Gio.FileInfo] = None) -> bool: 76 | """Checks if the given `item` is matched by the filter or not.""" 77 | if not file_info: 78 | return False 79 | 80 | return all( 81 | ( 82 | self.__search_filter(file_info), 83 | self.__hidden_filter(file_info), 84 | self.__tag_filter(file_info), 85 | ) 86 | ) 87 | -------------------------------------------------------------------------------- /hyperplane/item_sorter.py: -------------------------------------------------------------------------------- 1 | # item_sorter.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Main sorter for HypItemsPage.""" 21 | from locale import strcoll 22 | from typing import Optional 23 | 24 | from gi.repository import Gio, GLib, Gtk 25 | 26 | from hyperplane import shared 27 | 28 | 29 | class HypItemSorter(Gtk.Sorter): 30 | """Main sorter for HypItemsPage.""" 31 | 32 | __gtype_name__ = "HypItemSorter" 33 | 34 | def __init__(self, **kwargs) -> None: 35 | super().__init__(**kwargs) 36 | 37 | shared.postmaster.connect( 38 | "sort-changed", lambda *_: self.changed(Gtk.SorterChange.DIFFERENT) 39 | ) 40 | 41 | def do_compare( 42 | self, 43 | file_info1: Optional[Gio.FileInfo] = None, 44 | file_info2: Optional[Gio.FileInfo] = None, 45 | ) -> int: 46 | """ 47 | Compares two given items according to the sort order implemented by the sorter. 48 | 49 | Sorters implement a partial order: 50 | 51 | - It is reflexive, ie a = a 52 | - It is antisymmetric, ie if a < b and b < a, then a = b 53 | - It is transitive, ie given any 3 items with a ≤ b and b ≤ c, then a ≤ c 54 | 55 | The sorter may signal it conforms to additional constraints 56 | via the return value of `HypItemSorter.get_order()`. 57 | """ 58 | if (not file_info1) or (not file_info2): 59 | return Gtk.Ordering.EQUAL 60 | 61 | # Always sort trashed items by deletion date 62 | if ( 63 | ( 64 | gfile1 := file_info1.get_attribute_object("standard::file") 65 | ).get_uri_scheme() 66 | == "trash" 67 | # Only if the trashed file is at the toplevel of the trash 68 | and gfile1.get_uri().count("/") < 4 69 | ): 70 | if (not (deletion_date1 := file_info1.get_deletion_date())) or ( 71 | not (deletion_date2 := file_info2.get_deletion_date()) 72 | ): 73 | return Gtk.Ordering.EQUAL 74 | 75 | return self.__ordering_from_cmpfunc( 76 | GLib.DateTime.compare(deletion_date2, deletion_date1) 77 | ) 78 | 79 | # Always sort recent items by date 80 | if ( 81 | file_info1.get_attribute_object("standard::file").get_uri_scheme() 82 | == "recent" 83 | ): 84 | try: 85 | recent_info1 = shared.recent_manager.lookup_item( 86 | file_info1.get_attribute_string( 87 | Gio.FILE_ATTRIBUTE_STANDARD_TARGET_URI 88 | ) 89 | ) 90 | recent_info2 = shared.recent_manager.lookup_item( 91 | file_info2.get_attribute_string( 92 | Gio.FILE_ATTRIBUTE_STANDARD_TARGET_URI 93 | ) 94 | ) 95 | except GLib.Error: 96 | pass 97 | else: 98 | return self.__ordering_from_cmpfunc( 99 | GLib.DateTime.compare( 100 | recent_info2.get_modified(), recent_info1.get_modified() 101 | ) 102 | ) 103 | 104 | if shared.schema.get_boolean("folders-before-files"): 105 | if folders := self.__sort_folders_before_files(file_info1, file_info2): 106 | return folders 107 | 108 | name1 = file_info1.get_display_name() 109 | name2 = file_info2.get_display_name() 110 | 111 | # Sort dot-prefixed files last 112 | if name1.startswith("."): 113 | if not name2.startswith("."): 114 | return Gtk.Ordering.LARGER 115 | elif name2.startswith("."): 116 | return Gtk.Ordering.SMALLER 117 | 118 | match shared.sort_by: 119 | case "a-z": 120 | return self.__ordering_from_cmpfunc(strcoll(name1, name2)) 121 | 122 | case "modified": 123 | mod1 = file_info1.get_modification_date_time() 124 | mod2 = file_info2.get_modification_date_time() 125 | 126 | if mod1 and mod2: 127 | return self.__ordering_from_cmpfunc(mod2.compare(mod1)) 128 | 129 | case "created": 130 | created1 = file_info1.get_creation_date_time() 131 | created2 = file_info2.get_creation_date_time() 132 | 133 | if created1 and created2: 134 | return self.__ordering_from_cmpfunc(created2.compare(created1)) 135 | 136 | case "size": 137 | # No fast way to calculate size for folders so assume they're always bigger 138 | if folders := self.__sort_folders_before_files(file_info1, file_info2): 139 | return folders 140 | 141 | size1 = file_info1.get_size() 142 | size2 = file_info2.get_size() 143 | 144 | if size2 and size1: 145 | if size1 == size2: 146 | return Gtk.Ordering.EQUAL 147 | 148 | return self.__ordering_from_cmpfunc((int(size1 < size2) * 2) - 1) 149 | 150 | case "type": 151 | type1 = file_info1.get_content_type() 152 | type2 = file_info2.get_content_type() 153 | 154 | if type1 and type2: 155 | return self.__ordering_from_cmpfunc(strcoll(type2, type1)) 156 | 157 | # Fall back to A-Z 158 | return self.__ordering_from_cmpfunc(strcoll(name1, name2)) 159 | 160 | def __ordering_from_cmpfunc(self, cmpfunc_result: int) -> Gtk.Ordering: 161 | # https://gitlab.gnome.org/GNOME/gtk/-/issues/6298 162 | return Gtk.Ordering( 163 | ((cmpfunc_result > 0) - (cmpfunc_result < 0)) 164 | * (-1 if shared.sort_reversed else 1) 165 | ) 166 | 167 | def __sort_folders_before_files( 168 | self, file_info1: Gio.FileInfo, file_info2: Gio.FileInfo 169 | ) -> Gtk.Ordering: 170 | dir1 = file_info1.get_content_type() == "inode/directory" 171 | dir2 = file_info2.get_content_type() == "inode/directory" 172 | 173 | if dir1: 174 | if not dir2: 175 | return Gtk.Ordering.SMALLER 176 | elif dir2: 177 | return Gtk.Ordering.LARGER 178 | 179 | return None 180 | -------------------------------------------------------------------------------- /hyperplane/logging/color_log_formatter.py: -------------------------------------------------------------------------------- 1 | # color_log_formatter.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Formatter that outputs logs in a colored format.""" 21 | from logging import Formatter, LogRecord 22 | 23 | 24 | class ColorLogFormatter(Formatter): 25 | """Formatter that outputs logs in a colored format.""" 26 | 27 | RESET = "\033[0m" 28 | DIM = "\033[2m" 29 | BOLD = "\033[1m" 30 | RED = "\033[31m" 31 | YELLOW = "\033[33m" 32 | 33 | def format(self, record: LogRecord) -> str: 34 | super_format = super().format(record) 35 | match record.levelname: 36 | case "CRITICAL": 37 | return self.BOLD + self.RED + super_format + self.RESET 38 | case "ERROR": 39 | return self.RED + super_format + self.RESET 40 | case "WARNING": 41 | return self.YELLOW + super_format + self.RESET 42 | case "DEBUG": 43 | return self.DIM + super_format + self.RESET 44 | case _other: 45 | return super_format 46 | -------------------------------------------------------------------------------- /hyperplane/logging/logging_config.py: -------------------------------------------------------------------------------- 1 | # logging_config.py 2 | # 3 | # Copyright 2023 Geoffrey Coulaud 4 | # Copyright 2023-2024 kramo 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | # 19 | # SPDX-License-Identifier: GPL-3.0-or-later 20 | 21 | """Configures application-wide logging.""" 22 | from logging import config 23 | 24 | 25 | def logging_config() -> None: 26 | """Configures application-wide logging.""" 27 | config.dictConfig( 28 | { 29 | "version": 1, 30 | "formatters": { 31 | "console_formatter": { 32 | "format": "%(levelname)s - %(message)s", 33 | "class": "hyperplane.logging.color_log_formatter.ColorLogFormatter", 34 | }, 35 | }, 36 | "handlers": { 37 | "console_handler": { 38 | "class": "logging.StreamHandler", 39 | "formatter": "console_formatter", 40 | "level": "DEBUG", 41 | }, 42 | }, 43 | "root": { 44 | "level": "NOTSET", 45 | "handlers": ["console_handler"], 46 | }, 47 | } 48 | ) 49 | -------------------------------------------------------------------------------- /hyperplane/main.py: -------------------------------------------------------------------------------- 1 | # main.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """The main application singleton class.""" 21 | import logging 22 | import sys 23 | from typing import Any, Callable, Iterable, Optional, Sequence 24 | 25 | import gi 26 | 27 | gi.require_version("Gtk", "4.0") 28 | gi.require_version("Adw", "1") 29 | gi.require_version("GnomeDesktop", "4.0") 30 | gi.require_version("Xdp", "1.0") 31 | gi.require_version("XdpGtk4", "1.0") 32 | 33 | # pylint: disable=wrong-import-position 34 | 35 | from gi.repository import Adw, Gio, GLib, Gtk 36 | 37 | from hyperplane import shared 38 | from hyperplane.file_manager_dbus import FileManagerDBusServer 39 | from hyperplane.guide import HypGuide 40 | from hyperplane.logging.logging_config import logging_config 41 | from hyperplane.preferences import HypPreferencesDialog 42 | from hyperplane.window import HypWindow 43 | 44 | 45 | class HypApplication(Adw.Application): 46 | """The main application singleton class.""" 47 | 48 | guide: Optional[HypGuide] = None 49 | 50 | def __init__(self) -> None: 51 | super().__init__( 52 | application_id=shared.APP_ID, 53 | flags=Gio.ApplicationFlags.HANDLES_OPEN, 54 | ) 55 | logging_config() 56 | FileManagerDBusServer() 57 | 58 | shared.app = self 59 | 60 | new_window = GLib.OptionEntry() 61 | new_window.long_name = "new-window" 62 | new_window.short_name = ord("n") 63 | new_window.flags = int(GLib.OptionFlags.NONE) 64 | new_window.arg = int(GLib.OptionArg.NONE) 65 | new_window.arg_data = None 66 | new_window.description = "Open the app with a new window" 67 | new_window.arg_description = None 68 | 69 | self.add_main_option_entries((new_window,)) 70 | self.set_option_context_parameter_string("[DIRECTORIES]") 71 | 72 | self.create_action("quit", lambda *_: self.quit(), ("q",)) 73 | self.create_action("show-guide", self.__guide, ("F1",)) 74 | self.create_action("about", self.__about) 75 | self.create_action("preferences", self.__preferences, ("comma",)) 76 | 77 | # Show hidden 78 | show_hidden_action = Gio.SimpleAction.new_stateful( 79 | "show-hidden", None, shared.state_schema.get_value("show-hidden") 80 | ) 81 | show_hidden_action.connect("activate", self.__show_hidden) 82 | show_hidden_action.connect("change-state", self.__show_hidden) 83 | self.add_action(show_hidden_action) 84 | self.set_accels_for_action("app.show-hidden", ("h",)) 85 | 86 | # Sort by 87 | sort_action = Gio.SimpleAction.new_stateful( 88 | "sort", GLib.VariantType.new("s"), shared.state_schema.get_value("sort-by") 89 | ) 90 | sort_action.connect("activate", self.__sort) 91 | self.add_action(sort_action) 92 | 93 | # Reverse sort 94 | reverse_sort_action = Gio.SimpleAction.new_stateful( 95 | "reverse-sort", None, shared.state_schema.get_value("sort-reversed") 96 | ) 97 | reverse_sort_action.connect("activate", self.__reverse_sort) 98 | reverse_sort_action.connect("change-state", self.__reverse_sort) 99 | self.add_action(reverse_sort_action) 100 | 101 | # List/grid view 102 | change_view_action = Gio.SimpleAction.new_stateful( 103 | "change-view", 104 | GLib.VariantType.new("s"), 105 | GLib.Variant.new_string("grid" if shared.grid_view else "list"), 106 | ) 107 | change_view_action.connect("activate", self.__change_view) 108 | change_view_action.connect("change-state", self.__change_view) 109 | self.add_action(change_view_action) 110 | 111 | self.create_action( 112 | "list-view", 113 | lambda *_: self.__change_view( 114 | change_view_action, GLib.Variant.new_string("list") 115 | ), 116 | ("1",), 117 | ) 118 | self.create_action( 119 | "grid-view", 120 | lambda *_: self.__change_view( 121 | change_view_action, GLib.Variant.new_string("grid") 122 | ), 123 | ("2",), 124 | ) 125 | 126 | def do_open(self, gfiles: Sequence[Gio.File], _n_files: int, _hint: str) -> None: 127 | """Opens the given files.""" 128 | for gfile in gfiles: 129 | if ( 130 | gfile.query_file_type(Gio.FileQueryInfoFlags.NONE) 131 | != Gio.FileType.DIRECTORY 132 | ): 133 | logging.error("%s is not a directory.", gfile.get_uri()) 134 | return 135 | 136 | self.do_activate(gfile) 137 | 138 | def do_activate( 139 | self, 140 | gfile: Optional[Gio.File] = None, 141 | tags: Optional[Iterable[str]] = None, 142 | ) -> HypWindow: 143 | """Called when the application is activated.""" 144 | 145 | if not (gfile or tags): 146 | gfile = shared.home 147 | 148 | win = HypWindow(application=self, initial_gfile=gfile, initial_tags=tags) 149 | 150 | win.set_default_size( 151 | shared.state_schema.get_int("width"), 152 | shared.state_schema.get_int("height"), 153 | ) 154 | if shared.state_schema.get_boolean("is-maximized"): 155 | win.maximize() 156 | 157 | # Save window geometry 158 | shared.state_schema.bind( 159 | "width", win, "default-width", Gio.SettingsBindFlags.SET 160 | ) 161 | shared.state_schema.bind( 162 | "height", win, "default-height", Gio.SettingsBindFlags.SET 163 | ) 164 | shared.state_schema.bind( 165 | "is-maximized", win, "maximized", Gio.SettingsBindFlags.SET 166 | ) 167 | 168 | if not self.guide: 169 | self.guide = HypGuide() 170 | 171 | if shared.state_schema.get_boolean("first-run"): 172 | shared.state_schema.set_boolean("first-run", False) 173 | self.guide.present(win) 174 | 175 | win.present() 176 | 177 | return win 178 | 179 | def do_handle_local_options(self, options: GLib.VariantDict) -> int: 180 | """Handles local command line arguments.""" 181 | self.register() # This is so get_is_remote works 182 | if self.get_is_remote(): 183 | if options.contains("new-window"): 184 | return -1 185 | 186 | logging.warning( 187 | "Hyperplane is already running. " 188 | "To open a new window, run the app with --new-window." 189 | ) 190 | return 0 191 | 192 | return -1 193 | 194 | def create_action( 195 | self, name: str, callback: Callable, shortcuts: Optional[Iterable] = None 196 | ) -> None: 197 | """Add an application action. 198 | 199 | Args: 200 | name: the name of the action 201 | callback: the function to be called when the action is 202 | activated 203 | shortcuts: an optional list of accelerators 204 | """ 205 | action = Gio.SimpleAction.new(name, None) 206 | action.connect("activate", callback) 207 | self.add_action(action) 208 | if shortcuts: 209 | self.set_accels_for_action(f"app.{name}", shortcuts) 210 | 211 | def __guide(self, *_args: Any) -> None: 212 | self.guide.carousel.scroll_to(self.guide.page_1, False) 213 | self.guide.present(self.get_active_window()) 214 | 215 | def __about(self, *_args: Any) -> None: 216 | about = Adw.AboutDialog.new_from_appdata( 217 | shared.PREFIX + "/" + shared.APP_ID + ".metainfo.xml", shared.VERSION 218 | ) 219 | about.set_developers( 220 | ( 221 | "kramo https://kramo.page", 222 | "Benedek Dévényi https://github.com/rdbende", 223 | ) 224 | ) 225 | about.set_designers(("kramo https://kramo.page",)) 226 | about.set_copyright("© 2023-2024 kramo") 227 | # Translators: Replace this with your name for it to show up in the about window 228 | about.set_translator_credits = (_("translator_credits"),) 229 | about.present(self.get_active_window()) 230 | 231 | def __preferences(self, *_args: Any) -> None: 232 | if HypPreferencesDialog.is_open: 233 | return 234 | 235 | prefs = HypPreferencesDialog() 236 | prefs.present(self.get_active_window()) 237 | 238 | def __show_hidden(self, action: Gio.SimpleAction, _state: GLib.Variant) -> None: 239 | value = not action.props.state.get_boolean() 240 | action.set_state(GLib.Variant.new_boolean(value)) 241 | 242 | shared.state_schema.set_boolean("show-hidden", value) 243 | shared.show_hidden = value 244 | 245 | shared.postmaster.emit("toggle-hidden") 246 | 247 | def __sort(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: 248 | action.set_state(state) 249 | 250 | shared.sort_by = state.get_string() 251 | shared.postmaster.emit("sort-changed") 252 | 253 | shared.state_schema.set_string("sort-by", shared.sort_by) 254 | 255 | def __reverse_sort(self, action: Gio.SimpleAction, _state: GLib.Variant) -> None: 256 | value = not action.props.state.get_boolean() 257 | action.set_state(GLib.Variant.new_boolean(value)) 258 | 259 | shared.state_schema.set_boolean("sort-reversed", value) 260 | shared.sort_reversed = value 261 | 262 | shared.postmaster.emit("sort-changed") 263 | 264 | def __change_view(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: 265 | action.set_state(state) 266 | 267 | shared.grid_view = state.get_string() == "grid" 268 | shared.state_schema.set_boolean("grid-view", shared.grid_view) 269 | 270 | shared.postmaster.emit("view-changed") 271 | 272 | 273 | def main(_version): 274 | """The application's entry point.""" 275 | app = HypApplication() 276 | return app.run(sys.argv) 277 | -------------------------------------------------------------------------------- /hyperplane/meson.build: -------------------------------------------------------------------------------- 1 | moduledir = python_dir / 'hyperplane' 2 | 3 | blueprints = custom_target('blueprints', 4 | input: files( 5 | 'gtk/guide.blp', 6 | 'gtk/help-overlay.blp', 7 | 'gtk/item.blp', 8 | 'gtk/items-page.blp', 9 | 'gtk/new-file-dialog.blp', 10 | 'gtk/path-bar.blp', 11 | 'gtk/path-entry.blp', 12 | 'gtk/path-segment.blp', 13 | 'gtk/preferences.blp', 14 | 'gtk/volumes-box.blp', 15 | 'gtk/window.blp', 16 | ), 17 | output: '.', 18 | command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'], 19 | ) 20 | 21 | gnome.compile_resources('hyperplane', 22 | configure_file( 23 | input: 'hyperplane.gresource.xml.in', 24 | output: 'hyperplane.gresource.xml', 25 | configuration: conf 26 | ), 27 | gresource_bundle: true, 28 | install: true, 29 | install_dir: pkgdatadir, 30 | dependencies: blueprints, 31 | ) 32 | 33 | configure_file( 34 | input: 'hyperplane.in', 35 | output: 'hyperplane', 36 | configuration: conf, 37 | install: true, 38 | install_dir: get_option('bindir'), 39 | install_mode: 'r-xr-xr-x' 40 | ) 41 | 42 | install_subdir('utils', install_dir: moduledir) 43 | install_subdir('logging', install_dir: moduledir) 44 | 45 | if profile == 'development' 46 | install_subdir('devel', install_dir: moduledir) 47 | endif 48 | 49 | hyperplane_sources = [ 50 | '__init__.py', 51 | 'editable_row.py', 52 | 'file_manager_dbus.py', 53 | 'file_properties.py', 54 | 'guide.py', 55 | 'hover_page_opener.py', 56 | 'item_filter.py', 57 | 'item_sorter.py', 58 | 'item.py', 59 | 'items_page.py', 60 | 'main.py', 61 | 'navigation_bin.py', 62 | 'new_file_dialog.py', 63 | 'path_bar.py', 64 | 'path_entry.py', 65 | 'path_segment.py', 66 | 'postmaster_general.py', 67 | 'preferences.py', 68 | 'properties.py', 69 | 'tag_row.py', 70 | 'volumes_box.py', 71 | 'window.py', 72 | configure_file( 73 | input: 'shared.py.in', 74 | output: 'shared.py', 75 | configuration: conf 76 | ) 77 | ] 78 | 79 | install_data(hyperplane_sources, install_dir: moduledir) 80 | -------------------------------------------------------------------------------- /hyperplane/navigation_bin.py: -------------------------------------------------------------------------------- 1 | # navigation_bin.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """ 21 | An `AdwBin` with an `AdwNavigationView` child to be used 22 | with `HypItemsPage`s in its navigation stack. 23 | """ 24 | from typing import Any, Iterable, Optional 25 | 26 | from gi.repository import Adw, Gio, GLib 27 | 28 | from hyperplane.items_page import HypItemsPage 29 | 30 | 31 | class HypNavigationBin(Adw.Bin): 32 | """ 33 | An `AdwBin` with an `AdwNavigationView` child to be used 34 | with `HypItemsPage`s in its navigation stack. 35 | """ 36 | 37 | __gtype_name__ = "HypNavigationBin" 38 | 39 | items_page: HypItemsPage 40 | view: Adw.NavigationView 41 | 42 | next_pages: list[Adw.NavigationPage] 43 | 44 | def __init__( 45 | self, 46 | initial_gfile: Optional[Gio.File] = None, 47 | initial_tags: Optional[Iterable[str]] = None, 48 | **kwargs, 49 | ) -> None: 50 | super().__init__(**kwargs) 51 | self.view = Adw.NavigationView() 52 | self.view.add_css_class("flat-navigation-view") 53 | self.set_child(self.view) 54 | 55 | if initial_gfile: 56 | self.view.add(HypItemsPage(gfile=initial_gfile)) 57 | elif initial_tags: 58 | self.view.add(HypItemsPage(tags=list(initial_tags))) 59 | 60 | self.view.connect("popped", self.__popped) 61 | self.view.connect("pushed", self.__pushed) 62 | 63 | self.next_pages = [] 64 | self.view.connect("get-next-page", self.__next_page) 65 | 66 | def new_page( 67 | self, 68 | gfile: Optional[Gio.File] = None, 69 | tag: Optional[str] = None, 70 | tags: Optional[Iterable[str]] = None, 71 | ) -> None: 72 | """Push a new page with the given file or tag to the navigation stack.""" 73 | page = self.view.get_visible_page() 74 | next_page = self.next_pages[-1] if self.next_pages else None 75 | 76 | if gfile: 77 | if page.gfile and page.gfile.get_uri() == gfile.get_uri(): 78 | return 79 | 80 | if ( 81 | next_page 82 | and next_page.gfile 83 | and next_page.gfile.get_uri() == gfile.get_uri() 84 | ): 85 | self.view.push(next_page) 86 | return 87 | 88 | page = HypItemsPage(gfile=gfile) 89 | # Prefer tags over tag because of HypPathSegment, which has both 90 | elif tags: 91 | tags = list(tags) 92 | 93 | if page.tags == tags: 94 | return 95 | 96 | if next_page and next_page.tags == tags: 97 | self.view.push(next_page) 98 | return 99 | 100 | page = HypItemsPage(tags=tags) 101 | elif tag: 102 | if page.tags: 103 | if tag in page.tags: 104 | return 105 | 106 | tags = page.tags.copy() 107 | else: 108 | tags = [] 109 | 110 | tags.append(tag) 111 | 112 | if next_page and next_page.tags == tags: 113 | self.view.push(next_page) 114 | return 115 | 116 | page = HypItemsPage(tags=tags) 117 | else: 118 | return 119 | 120 | self.view.add(page) 121 | self.view.push(page) 122 | 123 | def __pushed(self, *_args: Any) -> None: 124 | page = self.view.get_visible_page() 125 | 126 | # HACK: find a proper way of doing this 127 | GLib.timeout_add(10, self.get_root().set_focus, page.scrolled_window) 128 | 129 | if not self.next_pages: 130 | return 131 | 132 | if page == self.next_pages[-1]: 133 | self.next_pages.pop() 134 | else: 135 | for next_page in self.next_pages: 136 | self.view.remove(next_page) 137 | 138 | self.next_pages = [] 139 | 140 | def __popped( 141 | self, 142 | _view: Adw.NavigationView, 143 | page: Adw.NavigationPage, 144 | ) -> None: 145 | self.next_pages.append(page) 146 | 147 | self.get_root().set_focus(self.view.get_visible_page().scrolled_window) 148 | 149 | def __next_page(self, *_args: Any) -> None: 150 | if not self.next_pages: 151 | return None 152 | 153 | return self.next_pages[-1] 154 | -------------------------------------------------------------------------------- /hyperplane/new_file_dialog.py: -------------------------------------------------------------------------------- 1 | # new_file_dialog.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A dialog for creating a new file based on a template.""" 21 | import logging 22 | from pathlib import Path 23 | from typing import Any, Optional 24 | 25 | from gi.repository import Adw, Gio, GLib, Gtk 26 | 27 | from hyperplane import shared 28 | from hyperplane.file_properties import DOT_IS_NOT_EXTENSION 29 | from hyperplane.utils.files import copy, validate_name 30 | from hyperplane.utils.symbolics import get_color_for_symbolic, get_symbolic 31 | 32 | 33 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/new-file-dialog.ui") 34 | class HypNewFileDialog(Adw.Dialog): 35 | """A dialog for creating a new file based on a template.""" 36 | 37 | __gtype_name__ = "HypNewFileDialog" 38 | 39 | active_gfile: Optional[Gio.File] = None 40 | 41 | toolbar_view: Adw.ToolbarView = Gtk.Template.Child() 42 | templates_folder_button: Gtk.Button = Gtk.Template.Child() 43 | files_page: Adw.PreferencesPage = Gtk.Template.Child() 44 | files_group: Adw.PreferencesGroup = Gtk.Template.Child() 45 | navigation_view: Adw.NavigationView = Gtk.Template.Child() 46 | name_page: Adw.NavigationPage = Gtk.Template.Child() 47 | name_text_view: Gtk.TextView = Gtk.Template.Child() 48 | icon_bin: Adw.Bin = Gtk.Template.Child() 49 | create_button: Gtk.Button = Gtk.Template.Child() 50 | warning_revealer: Gtk.Revealer = Gtk.Template.Child() 51 | warning_revealer_label: Gtk.Label = Gtk.Template.Child() 52 | 53 | def __init__(self, dst: Gio.File, **kwargs) -> None: 54 | super().__init__(**kwargs) 55 | self.dst = dst 56 | self.can_create = True 57 | 58 | self.templates_dir = GLib.get_user_special_dir( 59 | GLib.UserDirectory.DIRECTORY_TEMPLATES 60 | ) 61 | 62 | if not self.templates_dir: 63 | return 64 | 65 | self.templates_dir = Gio.File.new_for_path(self.templates_dir) 66 | 67 | if self.__get_template_children(self.templates_dir, self.files_group): 68 | self.toolbar_view.set_content(self.files_page) 69 | 70 | self.name_text_view.add_controller(controller := Gtk.ShortcutController.new()) 71 | controller.add_shortcut( 72 | Gtk.Shortcut.new( 73 | Gtk.ShortcutTrigger.parse_string("Return"), 74 | Gtk.CallbackAction.new(self.__copy_active_gfile), 75 | ) 76 | ) 77 | self.create_button.connect("clicked", self.__copy_active_gfile) 78 | self.templates_folder_button.connect("clicked", self.__open_templates) 79 | self.name_text_view.get_buffer().connect("changed", self.__text_changed) 80 | 81 | def __get_template_children( 82 | self, gfile: Gio.File, group: Adw.PreferencesGroup 83 | ) -> None: 84 | enumerator = gfile.enumerate_children( 85 | ",".join( 86 | ( 87 | Gio.FILE_ATTRIBUTE_STANDARD_NAME, 88 | Gio.FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, 89 | Gio.FILE_ATTRIBUTE_STANDARD_SYMBOLIC_ICON, 90 | Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE, 91 | ) 92 | ), 93 | Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, 94 | ) 95 | 96 | any_items = False 97 | while file_info := enumerator.next_file(): 98 | basename = file_info.get_name() 99 | 100 | if (not (child := gfile.get_child(basename))) or ( 101 | not (display_name := file_info.get_display_name()) 102 | ): 103 | continue 104 | 105 | content_type = file_info.get_content_type() 106 | 107 | if content_type == "inode/directory": 108 | # Nested templates 109 | new_page = Adw.NavigationPage(title=display_name) 110 | new_page.set_child(toolbar_view := Adw.ToolbarView()) 111 | toolbar_view.add_top_bar(Adw.HeaderBar()) 112 | toolbar_view.set_content(page := Adw.PreferencesPage()) 113 | page.add(child_group := Adw.PreferencesGroup()) 114 | 115 | row = Adw.ActionRow(title=display_name, activatable=True) 116 | row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) 117 | row.connect("activated", lambda *_: self.navigation_view.push(new_page)) 118 | group.add(row) 119 | 120 | self.__get_template_children(child, child_group) 121 | 122 | any_items = True 123 | continue 124 | 125 | if gicon := file_info.get_symbolic_icon(): 126 | gicon = get_symbolic(gicon) 127 | 128 | row = Adw.ActionRow(title=display_name, activatable=True) 129 | row.connect( 130 | "activated", 131 | self.__file_selected, 132 | content_type, 133 | gicon, 134 | display_name, 135 | child, 136 | ) 137 | 138 | if content_type and gicon: 139 | row.add_prefix( 140 | image := Gtk.Image( 141 | gicon=gicon, 142 | valign=Gtk.Align.CENTER, 143 | margin_top=9, 144 | margin_bottom=9, 145 | ) 146 | ) 147 | 148 | color = get_color_for_symbolic(content_type, gicon) 149 | 150 | image.add_css_class(color + "-icon") 151 | image.add_css_class(color + "-background") 152 | image.add_css_class("circular-icon") 153 | 154 | row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) 155 | 156 | group.add(row) 157 | any_items = True 158 | 159 | return any_items 160 | 161 | def __open_templates(self, *_args: Any) -> None: 162 | self.close() 163 | self.get_root().new_page(self.templates_dir) 164 | 165 | def __copy_active_gfile(self, *_args: Any) -> None: 166 | if not self.can_create: 167 | return 168 | 169 | self.close() 170 | 171 | buffer = self.name_text_view.get_buffer() 172 | 173 | if not ( 174 | text := buffer.get_text( 175 | buffer.get_start_iter(), buffer.get_end_iter(), False 176 | ).strip() 177 | ): 178 | logging.warning("No file name provided for template") 179 | return 180 | 181 | try: 182 | child = self.dst.get_child_for_display_name(text) 183 | except GLib.Error as error: 184 | logging.error("Cannot create template file: %s", error) 185 | return 186 | 187 | copy(self.active_gfile, child) 188 | 189 | def __file_selected( 190 | self, 191 | _row: Adw.ActionRow, 192 | content_type: Optional[str], 193 | gicon: Optional[Gio.Icon], 194 | display_name: str, 195 | gfile: Gio.File, 196 | ) -> None: 197 | self.active_gfile = gfile 198 | 199 | if content_type and gicon: 200 | self.icon_bin.set_child( 201 | image := Gtk.Image( 202 | icon_size=Gtk.IconSize.LARGE, gicon=gicon, valign=Gtk.Align.CENTER 203 | ) 204 | ) 205 | 206 | color = get_color_for_symbolic(content_type, gicon) 207 | 208 | image.add_css_class(color + "-icon") 209 | image.add_css_class(color + "-background") 210 | image.add_css_class("circular-icon") 211 | else: 212 | self.icon_bin.set_child(None) 213 | 214 | buffer = self.name_text_view.get_buffer() 215 | buffer.set_text(display_name) 216 | 217 | self.navigation_view.push(self.name_page) 218 | 219 | start = buffer.get_start_iter() 220 | end = buffer.get_iter_at_offset( 221 | len(display_name) 222 | if content_type in DOT_IS_NOT_EXTENSION 223 | else len(Path(display_name).stem) 224 | ) 225 | 226 | buffer.select_range(start, end) 227 | 228 | def __text_changed(self, buffer: Gtk.TextBuffer) -> None: 229 | text = buffer.get_text( 230 | buffer.get_start_iter(), buffer.get_end_iter(), False 231 | ).strip() 232 | 233 | if not text: 234 | self.can_create = False 235 | self.create_button.set_sensitive(False) 236 | self.warning_revealer.set_reveal_child(False) 237 | return 238 | 239 | self.can_create, message = validate_name(self.dst, text) 240 | self.create_button.set_sensitive(self.can_create) 241 | self.warning_revealer.set_reveal_child(bool(message)) 242 | if message: 243 | self.warning_revealer_label.set_label(message) 244 | -------------------------------------------------------------------------------- /hyperplane/path_bar.py: -------------------------------------------------------------------------------- 1 | # path_bar.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """The path bar in a HypWindow.""" 21 | import logging 22 | from os import sep 23 | from pathlib import Path 24 | from typing import Any, Iterable, Optional 25 | from urllib.parse import unquote, urlparse 26 | 27 | from gi.repository import Gdk, Gio, GLib, Gtk 28 | 29 | from hyperplane import shared 30 | from hyperplane.path_segment import HypPathSegment 31 | 32 | 33 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/path-bar.ui") 34 | class HypPathBar(Gtk.ScrolledWindow): 35 | """The path bar in a HypWindow.""" 36 | 37 | __gtype_name__ = "HypPathBar" 38 | 39 | viewport: Gtk.Viewport = Gtk.Template.Child() 40 | segments_box: Gtk.Box = Gtk.Template.Child() 41 | 42 | segments: list 43 | separators: dict 44 | tags: bool # Whether the path bar represents tags or a file 45 | 46 | def __init__(self, **kwargs) -> None: 47 | super().__init__(**kwargs) 48 | 49 | # Indicate that the bar is clickable 50 | self.set_cursor(Gdk.Cursor.new_from_name("text")) 51 | 52 | self.segments = [] 53 | self.separators = {} 54 | self.tags = False 55 | 56 | # Left-click 57 | self.segment_clicked = False 58 | left_click = Gtk.GestureClick(button=Gdk.BUTTON_PRIMARY) 59 | left_click.connect( 60 | "pressed", lambda *_: GLib.timeout_add(100, self.__left_click) 61 | ) 62 | self.add_controller(left_click) 63 | 64 | def remove(self, n: int) -> None: 65 | """Removes `n` number of segments form self, animating them.""" 66 | for _index in range(n): 67 | child = self.segments.pop() 68 | child.set_reveal_child(False) 69 | GLib.timeout_add( 70 | child.get_transition_duration(), 71 | self.__remove_child, 72 | self.segments_box, 73 | child, 74 | ) 75 | 76 | if not (separator := self.separators[child]): 77 | return 78 | 79 | separator.set_reveal_child(False) 80 | GLib.timeout_add( 81 | separator.get_transition_duration(), 82 | self.__remove_child, 83 | self.segments_box, 84 | separator, 85 | ) 86 | self.separators.pop(child) 87 | 88 | if self.tags: 89 | return 90 | 91 | try: 92 | self.segments[-1].active = True 93 | self.segments[-2].active = False 94 | except IndexError: 95 | return 96 | 97 | def append( 98 | self, 99 | label: str, 100 | icon_name: Optional[str] = None, 101 | uri: Optional[str] = None, 102 | tag: Optional[str] = None, 103 | ) -> None: 104 | """ 105 | Appends a HypPathSegment with `label` and `icon_name` to self. 106 | 107 | `uri` or `tag` will be opened when the segment is clicked. 108 | 109 | Adding an item is animated. 110 | """ 111 | if self.segments: 112 | # Add a separator only if there is more than one item 113 | sep_label = Gtk.Label.new("+" if self.tags else "/") 114 | sep_label.add_css_class("heading" if self.tags else "dim-label") 115 | 116 | separator = Gtk.Revealer( 117 | child=sep_label, transition_type=Gtk.RevealerTransitionType.SLIDE_RIGHT 118 | ) 119 | self.segments_box.append(separator) 120 | separator.set_reveal_child(True) 121 | else: 122 | separator = None 123 | 124 | segment = HypPathSegment(label, icon_name, uri, tag) 125 | self.segments_box.append(segment) 126 | 127 | segment.set_transition_type(Gtk.RevealerTransitionType.SLIDE_RIGHT) 128 | segment.set_reveal_child(True) 129 | 130 | self.separators[segment] = separator 131 | self.segments.append(segment) 132 | 133 | last_segment = self.segments[-1] 134 | 135 | GLib.timeout_add( 136 | last_segment.get_transition_duration(), 137 | self.viewport.scroll_to, 138 | last_segment, 139 | ) 140 | 141 | if self.tags: 142 | return 143 | 144 | last_segment.active = True 145 | 146 | try: 147 | self.segments[-2].active = False 148 | except IndexError: 149 | return 150 | 151 | def purge(self) -> None: 152 | """Removes all segments from self, without animation.""" 153 | while child := self.segments_box.get_first_child(): 154 | self.segments_box.remove(child) 155 | 156 | self.segments = [] 157 | self.separators = {} 158 | 159 | def update(self, gfile: Optional[Gio.File], tags: Optional[Iterable[str]]) -> None: 160 | """Updates the bar according to a new `gfile` or new `tags`.""" 161 | if gfile: 162 | if self.tags: 163 | self.purge() 164 | 165 | self.tags = False 166 | 167 | uri = gfile.get_uri() 168 | parse = urlparse(uri) 169 | segments = [] 170 | scheme_uri = f"{parse.scheme}://" 171 | 172 | # Do these automatically if shceme != "file" 173 | if parse.scheme == "file": 174 | base_uri = scheme_uri 175 | else: 176 | try: 177 | file_info = Gio.File.new_for_uri(scheme_uri).query_info( 178 | ",".join( 179 | ( 180 | Gio.FILE_ATTRIBUTE_STANDARD_SYMBOLIC_ICON, 181 | Gio.FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, 182 | ) 183 | ), 184 | Gio.FileQueryInfoFlags.NONE, 185 | ) 186 | 187 | base_name = file_info.get_display_name() 188 | base_symbolic = file_info.get_symbolic_icon().get_names()[0] 189 | base_uri = scheme_uri 190 | except GLib.Error: 191 | # Try the mount if the scheme root fails 192 | try: 193 | mount = gfile.find_enclosing_mount() 194 | mount_gfile = mount.get_default_location() 195 | 196 | base_name = mount.get_name() 197 | base_symbolic = mount.get_symbolic_icon().get_names()[0] 198 | base_uri = mount_gfile.get_uri() 199 | except GLib.Error as error: 200 | base_name = None 201 | base_symbolic = None 202 | base_uri = None 203 | logging.error( 204 | 'Cannot get information for location "%s": %s', uri, error 205 | ) 206 | 207 | parts = unquote(parse.path).split(sep) 208 | 209 | for index, part in enumerate(parts): 210 | if not part: 211 | continue 212 | 213 | segments.append( 214 | (part, "", f"{base_uri}{sep.join(parts[:index+1])}", None) 215 | ) 216 | 217 | if (path := gfile.get_path()) and ( 218 | (path := Path(path)) == shared.home_path 219 | or path.is_relative_to(shared.home_path) 220 | ): 221 | segments = segments[len(shared.home_path.parts) - 1 :] 222 | base_name = _("Home") 223 | base_symbolic = "user-home-symbolic" 224 | base_uri = shared.home.get_uri() 225 | elif parse.scheme == "file": 226 | # Not relative to home, so add a root segment 227 | base_name = "" 228 | base_symbolic = "drive-harddisk-symbolic" 229 | # Fall back to sep if the GFile doesn't have a path 230 | base_uri = Path(path.anchor if path else sep).as_uri() 231 | 232 | if base_uri: 233 | segments.insert( 234 | 0, 235 | ( 236 | base_name, 237 | base_symbolic, 238 | base_uri, 239 | None, 240 | ), 241 | ) 242 | 243 | elif tags: 244 | if not self.tags: 245 | self.purge() 246 | 247 | self.tags = True 248 | 249 | segments = tuple((tag, "", None, tag) for tag in tags) 250 | 251 | if (old_len := len(self.segments)) > (new_len := len(segments)): 252 | self.remove(old_len - new_len) 253 | 254 | append = False 255 | for index, new_segment in enumerate(segments): 256 | try: 257 | old_segment = self.segments[index] 258 | except IndexError: 259 | old_segment = None 260 | 261 | if ( 262 | not append 263 | and old_segment 264 | and new_segment[2] == old_segment.uri 265 | and new_segment[3] == old_segment.tag 266 | ): 267 | continue 268 | 269 | if not append: 270 | self.remove(len(self.segments) - index) 271 | append = True 272 | 273 | self.append(*new_segment) 274 | 275 | def __remove_child(self, parent: Gtk.Box, child: Gtk.Widget) -> None: 276 | # This is so GTK doesn't freak out when the child isn't in the parent anymore 277 | if child.get_parent == parent: 278 | parent.remove(child) 279 | 280 | def __left_click(self, *_args: Any) -> None: 281 | # Do nothing if a segment has been clicked recently 282 | if self.segment_clicked: 283 | self.segment_clicked = False 284 | return 285 | 286 | self.get_root().show_path_entry() 287 | -------------------------------------------------------------------------------- /hyperplane/path_entry.py: -------------------------------------------------------------------------------- 1 | # path_entry.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """An entry for navigating to paths or tags.""" 21 | from os import sep 22 | from pathlib import Path 23 | from typing import Any, Iterable, Optional 24 | from urllib.parse import quote, unquote, urlparse 25 | 26 | from gi.repository import Gdk, Gio, GObject, Gtk 27 | 28 | from hyperplane import shared 29 | from hyperplane.utils.files import get_gfile_path 30 | 31 | 32 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/path-entry.ui") 33 | class HypPathEntry(Gtk.Entry): 34 | """An entry for navigating to paths or tags.""" 35 | 36 | __gtype_name__ = "HypPathEntry" 37 | 38 | completer = Gio.FilenameCompleter.new() 39 | completer.set_dirs_only(True) 40 | 41 | def __init__(self, **kwargs) -> None: 42 | super().__init__(**kwargs) 43 | self.connect("activate", self.__activate) 44 | self.connect("changed", self.__complete) 45 | self.prev_text = "" 46 | self.prev_completion = "" 47 | 48 | # Capture the tab key 49 | controller = Gtk.EventControllerKey.new() 50 | controller.connect("key-pressed", self.__key_pressed) 51 | self.add_controller(controller) 52 | 53 | def new_path( 54 | self, gfile: Optional[Gio.File], tags: Optional[Iterable[str]] 55 | ) -> None: 56 | """Sets the text of the entry to the path (or URI) of `gfile` or `tags`.""" 57 | 58 | if tags: 59 | self.set_text(f'{"//"}{"//".join(tags)}{"//"}') 60 | return 61 | 62 | if not gfile: 63 | return 64 | 65 | if gfile.get_uri_scheme() == "file": 66 | try: 67 | path = get_gfile_path(gfile, uri_fallback=True) 68 | except FileNotFoundError: 69 | path = unquote(gfile.get_uri()) 70 | else: 71 | path = unquote(gfile.get_uri()) 72 | 73 | self.set_text( 74 | path 75 | if isinstance(path, str) 76 | else ( 77 | str(path) 78 | if str(path) == sep # If the path is root 79 | else str(path) + sep 80 | ) 81 | ) 82 | 83 | @GObject.Signal(name="hide-entry") 84 | def hide(self) -> None: 85 | """ 86 | Emitted to indicate that the entry is done and should be hidden. 87 | 88 | Containers of this widget should connect to it and react accordingly. 89 | """ 90 | 91 | # https://github.com/GNOME/nautilus/blob/5e8037c109fc00ba3778193404914db73f8fe95c/src/nautilus-location-entry.c#L511 92 | def __key_pressed( 93 | self, 94 | _controller: Gtk.EventControllerKey, 95 | keyval: int, 96 | _keycode: int, 97 | _state: Gdk.ModifierType, 98 | ) -> None: 99 | if keyval == Gdk.KEY_Tab: 100 | if self.get_selection_bounds(): 101 | self.select_region(-1, -1) 102 | else: 103 | self.error_bell() 104 | 105 | return Gdk.EVENT_STOP 106 | 107 | return Gdk.EVENT_PROPAGATE 108 | 109 | def __complete(self, *_args: Any) -> None: 110 | text = self.get_text() 111 | 112 | # If the user is typing a tag, return 113 | if text.startswith("//"): 114 | return 115 | 116 | relative_completion = False 117 | if not (completion := self.completer.get_completion_suffix(text)) and ( 118 | gfile := self.get_root().get_visible_page().gfile 119 | ): 120 | completion = self.completer.get_completion_suffix( 121 | f"{gfile.get_uri()}/{quote(text)}" 122 | ) 123 | relative_completion = True 124 | 125 | if not completion: 126 | return 127 | 128 | # Unquote of the completion is for a URI 129 | if "://" in text or relative_completion: 130 | completion = unquote(completion) 131 | 132 | # If a deletion happened, return 133 | # There is probably a more logical way to do this 134 | if ( 135 | (len(completion) == 2 and not self.prev_completion.endswith(completion)) 136 | or completion.endswith(self.prev_completion) 137 | or ( 138 | self.prev_text.startswith(text) 139 | and self.prev_completion.endswith(completion) 140 | ) 141 | ): 142 | self.prev_text = text 143 | self.prev_completion = completion 144 | return 145 | 146 | self.prev_text = text 147 | self.prev_completion = completion 148 | 149 | text_length = self.get_text_length() 150 | new_text = text + completion 151 | 152 | # Set the buffer directly so GTK doesn't freak out 153 | self.get_buffer().set_text(new_text, len(new_text)) 154 | self.select_region(text_length, -1) 155 | 156 | def __activate(self, entry, *_args: Any) -> None: 157 | text = entry.get_text().strip() 158 | 159 | if text.startswith("//"): 160 | tags = list( 161 | tag 162 | for tag in shared.tags 163 | if tag in text.lstrip("/").rstrip("/").split("//") 164 | ) 165 | 166 | if not tags: 167 | self.get_root().send_toast(_("No such tags")) 168 | return 169 | 170 | self.emit("hide-entry") 171 | self.get_root().new_page(tags=tags) 172 | return 173 | 174 | if "://" in text: 175 | # Don't quote the scheme 176 | prefix = f"{urlparse(text).scheme}://" 177 | gfile = Gio.File.new_for_uri(f"{prefix}{quote(text.removeprefix(prefix))}") 178 | else: 179 | gfile = Gio.File.new_for_path(str(Path(text).expanduser())) 180 | 181 | # If neither the absolute nor relative path is valid 182 | if not ( 183 | gfile.query_file_type(Gio.FileQueryInfoFlags.NONE) == Gio.FileType.DIRECTORY 184 | or ( 185 | not "://" in text 186 | and (page_gfile := self.get_root().get_visible_page().gfile) 187 | and ( 188 | gfile := Gio.File.new_for_uri( 189 | f"{page_gfile.get_uri()}/{quote(text)}" 190 | ) 191 | ).query_file_type(Gio.FileQueryInfoFlags.NONE) 192 | == Gio.FileType.DIRECTORY 193 | ) 194 | ): 195 | self.get_root().send_toast(_("Unable to find path")) 196 | return 197 | 198 | self.emit("hide-entry") 199 | 200 | self.get_root().new_page(gfile) 201 | -------------------------------------------------------------------------------- /hyperplane/path_segment.py: -------------------------------------------------------------------------------- 1 | # path_segment.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A segment in a HypPathBar.""" 21 | from typing import Any, Optional 22 | 23 | from gi.repository import Adw, Gdk, Gio, GObject, Gtk 24 | 25 | from hyperplane import shared 26 | from hyperplane.hover_page_opener import HypHoverPageOpener 27 | 28 | 29 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/path-segment.ui") 30 | class HypPathSegment(Gtk.Revealer, HypHoverPageOpener): 31 | """A segment in a HypPathBar.""" 32 | 33 | __gtype_name__ = "HypPathSegment" 34 | 35 | button: Gtk.Button = Gtk.Template.Child() 36 | button_content: Adw.ButtonContent = Gtk.Template.Child() 37 | 38 | _active: bool 39 | 40 | def __init__( 41 | self, 42 | label: str, 43 | icon_name: Optional[str] = None, 44 | uri: Optional[str] = None, 45 | tag: Optional[str] = None, 46 | **kwargs 47 | ) -> None: 48 | super().__init__(**kwargs) 49 | HypHoverPageOpener.__init__(self) 50 | 51 | # Because HypPathBar sets it to "text" 52 | self.set_cursor(Gdk.Cursor.new_from_name("default")) 53 | 54 | self.icon_name = icon_name 55 | self.label = label 56 | self.uri = uri 57 | self.gfile = Gio.File.new_for_uri(self.uri) if self.uri else None 58 | self.tag = tag 59 | 60 | # This is needed for HypHoverPageOpener 61 | self.tags = [tag] 62 | 63 | middle_click = Gtk.GestureClick(button=Gdk.BUTTON_MIDDLE) 64 | middle_click.connect( 65 | "pressed", 66 | lambda *_: self.get_root().new_tab( 67 | self.gfile, tags=[self.tag] if self.tag else None 68 | ), 69 | ) 70 | self.add_controller(middle_click) 71 | 72 | self.button.connect("clicked", self.__navigate) 73 | 74 | @GObject.Property(type=bool, default=True) 75 | def active(self) -> bool: 76 | """Whether the segment is the currently active one.""" 77 | return self._active 78 | 79 | @active.setter 80 | def set_active(self, active) -> None: 81 | self._active = active 82 | (self.remove_css_class if active else self.add_css_class)("inactive-segment") 83 | 84 | @GObject.Property(type=str) 85 | def icon_name(self) -> str: 86 | """An optional icon for the path segment.""" 87 | return self.button_content.get_icon_name() 88 | 89 | @icon_name.setter 90 | def set_icon_name(self, icon_name: str) -> None: 91 | if not icon_name: 92 | self.button_content.set_visible(False) 93 | return 94 | self.button_content.set_icon_name(icon_name) 95 | 96 | @GObject.Property(type=str) 97 | def label(self) -> str: 98 | """The label of the path segment.""" 99 | return ( 100 | self.button_content.get_label() 101 | if self.icon_name 102 | else self.button.get_label() 103 | ) 104 | 105 | @label.setter 106 | def set_label(self, label: str) -> None: 107 | (self.button_content if self.icon_name else self.button).set_label(label) 108 | 109 | def __navigate(self, *_args: Any) -> None: 110 | # TODO: Ugly but simpler than the alternative 111 | self.get_parent().get_parent().get_parent().segment_clicked = True 112 | 113 | if self.tag: 114 | self.get_root().new_page(tags=[self.tag]) 115 | return 116 | 117 | if self.gfile: 118 | if self.active: # pylint: disable=using-constant-test 119 | return 120 | 121 | self.get_root().new_page(self.gfile) 122 | -------------------------------------------------------------------------------- /hyperplane/postmaster_general.py: -------------------------------------------------------------------------------- 1 | # postmaster_general.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A singleton class for sending signals throughout the app.""" 21 | from gi.repository import Gio, GObject, Gtk 22 | 23 | 24 | class HypPostmasterGeneral(GObject.Object): 25 | """A singleton class for sending signals throughout the app.""" 26 | 27 | __gtype_name__ = "HypPostmasterGeneral" 28 | 29 | @GObject.Signal(name="zoom") 30 | def zoom(self, zoom_level: int) -> None: 31 | """ 32 | Emitted whenever the zoom level changes. 33 | 34 | All widgets that are affected by zooming should connect to it. 35 | """ 36 | 37 | @GObject.Signal(name="toggle-hidden") 38 | def toggle_hidden(self) -> None: 39 | """Emitted when the visibility of hidden files changes.""" 40 | 41 | @GObject.Signal(name="tags-changed") 42 | def tags_changed(self, change: Gtk.FilterChange) -> None: 43 | """ 44 | Emitted whenever the list of tags changes. 45 | 46 | All objects that keep an internal list of tags should connect to it 47 | and update their list accordingly. 48 | 49 | `change` indicates whether tags were added, removed or just reordered. 50 | This is only relevant for item filters. 51 | """ 52 | 53 | @GObject.Signal(name="tag-location-created") 54 | def tag_location_created( 55 | self, tags: Gtk.StringList, new_location: Gio.File 56 | ) -> None: 57 | """ 58 | Emitted whenever a new directory is created for tags. 59 | 60 | All widgets that display items from tags should connect to it 61 | and set it up so they append `new_location` to their list of locations 62 | if all `tags` are in their tags. 63 | """ 64 | 65 | @GObject.Signal(name="trash-emptied") 66 | def trash_emptied(self) -> None: 67 | """Emitted when the trash is emptied by the app.""" 68 | 69 | @GObject.Signal(name="sidebar-edited") 70 | def sidebar_changed(self) -> None: 71 | """Emitted when the visibility of items in the sidebar has been edited.""" 72 | 73 | @GObject.Signal(name="cut-uris-changed") 74 | def cut_files_changed(self) -> None: 75 | """ 76 | Emitted whenever a cut operation starts, finishes or is cancelled. 77 | 78 | Widgets represeting files should connect to this, and 79 | if the URI of the file they represent is in `shared.cut_uris`, react accordingly. 80 | """ 81 | 82 | @GObject.Signal(name="view-changed") 83 | def view_changed(self) -> None: 84 | """Emitted when the view changes from grid to list or vice versa.""" 85 | 86 | @GObject.Signal(name="sort-changed") 87 | def sort_changed(self) -> None: 88 | """ 89 | Emitted when the sorting of items changes, for example from 'a-z' to 'modified'. 90 | 91 | It is also emitted when the sort direction reverses. 92 | 93 | Sorters should connect to it and update their items accordingly. 94 | """ 95 | -------------------------------------------------------------------------------- /hyperplane/preferences.py: -------------------------------------------------------------------------------- 1 | # preferences.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """The main preferences window.""" 21 | from gi.repository import Adw, Gio, Gtk 22 | 23 | from hyperplane import shared 24 | 25 | 26 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/preferences.ui") 27 | class HypPreferencesDialog(Adw.PreferencesDialog): 28 | """The main preferences window.""" 29 | 30 | __gtype_name__ = "HypPreferencesDialog" 31 | 32 | folders_switch_row = Gtk.Template.Child() 33 | single_click_open_switch_row = Gtk.Template.Child() 34 | 35 | is_open = False 36 | 37 | def __init__(self, **kwargs) -> None: 38 | super().__init__(**kwargs) 39 | 40 | # Make it so only one dialog can be open at a time 41 | self.__class__.is_open = True 42 | self.connect("closed", lambda *_: self.set_is_open(False)) 43 | 44 | shared.schema.bind( 45 | "folders-before-files", 46 | self.folders_switch_row, 47 | "active", 48 | Gio.SettingsBindFlags.DEFAULT, 49 | ) 50 | 51 | shared.schema.bind( 52 | "single-click-open", 53 | self.single_click_open_switch_row, 54 | "active", 55 | Gio.SettingsBindFlags.DEFAULT, 56 | ) 57 | 58 | self.folders_switch_row.connect( 59 | "notify::active", lambda *_: shared.postmaster.emit("sort-changed") 60 | ) 61 | 62 | def set_is_open(self, is_open: bool) -> None: 63 | self.__class__.is_open = is_open 64 | -------------------------------------------------------------------------------- /hyperplane/shared.py.in: -------------------------------------------------------------------------------- 1 | # shared.py.in 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Shared data across the application.""" 21 | from os import getenv 22 | from pathlib import Path 23 | 24 | from gi.repository import Gdk, Gio, Gtk 25 | 26 | from hyperplane.postmaster_general import HypPostmasterGeneral 27 | 28 | APP_ID = "@APP_ID@" 29 | VERSION = "@VERSION@" 30 | PREFIX = "@PREFIX@" 31 | PROFILE = "@PROFILE@" 32 | 33 | schema = Gio.Settings.new(APP_ID) 34 | state_schema = Gio.Settings.new(APP_ID + ".State") 35 | 36 | app = None # pylint: disable=invalid-name 37 | search = "" # pylint: disable=invalid-name 38 | right_clicked_file = None # pylint: disable=invalid-name 39 | undo_queue = {} 40 | 41 | grid_view = state_schema.get_boolean("grid-view") 42 | show_hidden = state_schema.get_boolean("show-hidden") 43 | sort_by = state_schema.get_string("sort-by") 44 | sort_reversed = state_schema.get_boolean("sort-reversed") 45 | 46 | home_path = Path(getenv("HYPHOME", str(Path.home()))).expanduser() 47 | home = Gio.File.new_for_path(str(home_path)) 48 | 49 | # Create home if it doesn't exist 50 | home_path.mkdir(parents=True, exist_ok=True) 51 | 52 | if (path := home_path / ".hyperplane").is_file(): 53 | tags = list( 54 | tag for tag in path.read_text(encoding="utf-8").strip().split("\n") if tag 55 | ) 56 | else: 57 | # Default tags 58 | tags = [_("Documents"), _("Music"), _("Pictures"), _("Videos")] 59 | path.write_text("\n".join(tags), encoding="utf-8") 60 | 61 | del path 62 | 63 | postmaster = HypPostmasterGeneral() 64 | 65 | closed_folder_texture = Gdk.Texture.new_from_resource( 66 | PREFIX + "/assets/folder-closed.svg" 67 | ) 68 | open_folder_texture = Gdk.Texture.new_from_resource( 69 | PREFIX + "/assets/folder-open.svg" 70 | ) 71 | 72 | trash_list = Gtk.DirectoryList.new(None, Gio.File.new_for_uri("trash://")) 73 | 74 | is_flatpak = getenv("FLATPAK_ID") == APP_ID 75 | 76 | if ( 77 | is_flatpak 78 | and ( 79 | path := Path( 80 | getenv("HOST_XDG_DATA_HOME", str(Path.home() / ".local" / "share")) 81 | ) 82 | / "recently-used.xbel" 83 | ).is_file() 84 | ): 85 | # Use the system-wide RecentManager instead of the application-specific one 86 | # so Hyperplane's recent files are visible to GVFS 87 | recent_manager = Gtk.RecentManager(filename=str(path)) 88 | else: 89 | recent_manager = Gtk.RecentManager.get_default() 90 | 91 | cut_uris = set() 92 | 93 | 94 | def set_cut_uris(uris: set[str]) -> None: 95 | """ 96 | Sets URIs representing files that are going to be moved after a paste operation. 97 | 98 | This is so the widgets' "opacities" can be reduced. 99 | """ 100 | global cut_uris # pylint: disable=global-statement 101 | 102 | cut_uris = uris 103 | 104 | postmaster.emit("cut-uris-changed") 105 | -------------------------------------------------------------------------------- /hyperplane/tag_row.py: -------------------------------------------------------------------------------- 1 | # tag_row.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """A row in the sidebar representing a tag.""" 21 | from typing import Self 22 | 23 | from gi.repository import Gdk, Gtk 24 | 25 | from hyperplane import shared 26 | from hyperplane.editable_row import HypEditableRow 27 | from hyperplane.utils.tags import update_tags 28 | 29 | 30 | class HypTagRow(HypEditableRow): 31 | """A row in the sidebar representing a tag.""" 32 | 33 | __gtype_name__ = "HypTagRow" 34 | 35 | tag: str 36 | 37 | def __init__(self, tag: str, icon_name: str, **kwargs) -> None: 38 | super().__init__(identifier=f"tag_{tag}", **kwargs) 39 | self.title = self.tag = tag 40 | self.icon_name = icon_name 41 | self.editable = False 42 | 43 | right_click = Gtk.GestureClick(button=Gdk.BUTTON_SECONDARY) 44 | right_click.connect("pressed", self.__right_click) 45 | self.add_controller(right_click) 46 | 47 | middle_click = Gtk.GestureClick(button=Gdk.BUTTON_MIDDLE) 48 | middle_click.connect( 49 | "pressed", lambda *_: self.get_root().new_tab(tags=[self.tag]) 50 | ) 51 | self.add_controller(middle_click) 52 | 53 | # Drag and drop 54 | drag_source = Gtk.DragSource.new() 55 | drag_source.connect("prepare", self.__drag_prepare) 56 | drag_source.connect("drag-begin", self.__drag_begin) 57 | drag_source.set_actions(Gdk.DragAction.MOVE) 58 | self.box.add_controller(drag_source) 59 | 60 | drop_target = Gtk.DropTarget.new(HypTagRow, Gdk.DragAction.MOVE) 61 | drop_target.connect("enter", self.__drop_enter) 62 | drop_target.connect("leave", self.__drop_leave) 63 | drop_target.connect("drop", self.__drop) 64 | self.add_controller(drop_target) 65 | 66 | def __drag_prepare(self, _src: Gtk.DragSource, _x: float, _y: float) -> None: 67 | return Gdk.ContentProvider.new_for_value(self) 68 | 69 | def __drag_begin(self, src: Gtk.DragSource, _drag: Gdk.Drag) -> None: 70 | src.set_icon(Gtk.WidgetPaintable.new(self.box), -30, 0) 71 | 72 | def __drop_enter(self, target: Gtk.DropTarget, _x: float, _y: float) -> None: 73 | try: 74 | gtypes = ( 75 | target.get_current_drop() 76 | .get_drag() 77 | .get_content() 78 | .ref_formats() 79 | .get_gtypes() 80 | ) 81 | except TypeError: 82 | return Gdk.DragAction.MOVE 83 | 84 | if gtypes and gtypes[0].pytype == type(self): 85 | self.can_open_page = False 86 | self.add_css_class("sidebar-drop-target") 87 | 88 | return Gdk.DragAction.MOVE 89 | 90 | def __drop_leave(self, _target: Gtk.DropTarget) -> None: 91 | self.can_open_page = True 92 | self.remove_css_class("sidebar-drop-target") 93 | 94 | def __drop(self, _target: Gtk.DropTarget, row: Self, _x: float, _y: float) -> None: 95 | self_index = shared.tags.index(self.tag) 96 | row_index = shared.tags.index(row.tag) 97 | 98 | shared.tags.insert( 99 | # Offset the index by 1 if `row.tag` is at a larger index than `self.tag` 100 | self_index + int(self_index < row_index), 101 | shared.tags.pop(row_index), 102 | ) 103 | 104 | update_tags() 105 | 106 | def __right_click( 107 | self, _gesture: Gtk.GestureClick, _n: int, x: float, y: float 108 | ) -> None: 109 | self.get_root().right_clicked_tag = self.tag 110 | 111 | # Disable move up/down actions if the tag is the first/last in the list 112 | self.get_root().lookup_action("move-tag-up").set_enabled( 113 | shared.tags[0] != self.tag 114 | ) 115 | self.get_root().lookup_action("move-tag-down").set_enabled( 116 | shared.tags[-1] != self.tag 117 | ) 118 | 119 | self.get_root().tag_right_click_menu.unparent() 120 | self.get_root().tag_right_click_menu.set_parent(self) 121 | rectangle = Gdk.Rectangle() 122 | rectangle.x, rectangle.y, rectangle.width, rectangle.height = x, y, 0, 0 123 | self.get_root().tag_right_click_menu.set_pointing_to(rectangle) 124 | self.get_root().tag_right_click_menu.popup() 125 | -------------------------------------------------------------------------------- /hyperplane/utils/create_alert_dialog.py: -------------------------------------------------------------------------------- 1 | # create_alert_dialog.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Returns an `AdwAlertDialog` with the given properties.""" 21 | from typing import Callable, Optional 22 | 23 | from gi.repository import Adw, Gtk 24 | 25 | 26 | def create_alert_dialog( 27 | heading: str, 28 | *responses: tuple[ 29 | str, # Name 30 | Optional[str], # ID 31 | Optional[Adw.ResponseAppearance], # Appearance 32 | Optional[Callable], # Callback 33 | bool, # Default 34 | ], 35 | body: Optional[str] = None, 36 | extra_child: Optional[Gtk.Widget] = None, 37 | ) -> Adw.AlertDialog: 38 | """Returns an `AdwAlertDialog` with the given properties.""" 39 | dialog = Adw.AlertDialog.new(heading, body) 40 | 41 | if extra_child: 42 | dialog.set_extra_child(extra_child) 43 | 44 | callables = {} 45 | 46 | for index, response in enumerate(responses): 47 | response_id = response[1] or str(index) 48 | 49 | dialog.add_response(response_id, response[0]) 50 | 51 | if response[2]: 52 | dialog.set_response_appearance(response_id, response[2]) 53 | 54 | if response[3]: 55 | callables[response_id] = response[3] 56 | 57 | if response[4]: 58 | dialog.set_default_response(response_id) 59 | 60 | def handle_response(_dialog: Adw.AlertDialog, response: str) -> None: 61 | if response in callables: 62 | callables[response]() 63 | 64 | dialog.connect("response", handle_response) 65 | return dialog 66 | -------------------------------------------------------------------------------- /hyperplane/utils/dates.py: -------------------------------------------------------------------------------- 1 | # dates.py 2 | # 3 | # Copyright 2022-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Miscellaneous utilities for working with dates.""" 21 | from typing import Any 22 | 23 | from gi.repository import GLib 24 | 25 | 26 | def relative_date( 27 | date_time: GLib.DateTime, 28 | ) -> Any: # pylint: disable=too-many-return-statements 29 | """Gets a relative date (compared to now) for a `GDateTime`.""" 30 | 31 | # Return "-" instead of 1970 32 | if not date_time.to_unix(): 33 | return "-" 34 | 35 | n_days = GLib.DateTime.new_now_utc().difference(date_time) / 86400000000 36 | 37 | if n_days == 0: 38 | return _("Today") 39 | if n_days == 1: 40 | return _("Yesterday") 41 | if n_days <= (day_of_week := date_time.get_day_of_week()): 42 | return date_time.format("%A") 43 | if n_days <= day_of_week + 7: 44 | return _("Last Week") 45 | if n_days <= (day_of_month := date_time.get_day_of_month()): 46 | return _("This Month") 47 | if n_days <= day_of_month + 30: 48 | return _("Last Month") 49 | if n_days < (day_of_year := date_time.get_day_of_year()): 50 | return date_time.format("%B") 51 | if n_days <= day_of_year + 365: 52 | return _("Last Year") 53 | return date_time.format("%Y") 54 | -------------------------------------------------------------------------------- /hyperplane/utils/iterplane.py: -------------------------------------------------------------------------------- 1 | # iterplane.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Get the existing paths that contain files tagged `filter_tags`.""" 21 | from pathlib import Path 22 | from typing import Generator, Iterable 23 | 24 | from hyperplane import shared 25 | 26 | 27 | def iterplane(filter_tags: Iterable[str]) -> Generator: 28 | """Get the existing paths that contain files tagged `filter_tags`.""" 29 | if not filter_tags: 30 | return 31 | 32 | tags = {tag: tag in filter_tags for tag in shared.tags} 33 | 34 | yield from __walk(shared.home_path, tags) 35 | 36 | 37 | def __walk(node: Path, tags: dict[str, bool]) -> Generator: 38 | if tags.get(node.name): 39 | tags.pop(node.name) 40 | 41 | if not sum(tags.values()): 42 | yield node 43 | 44 | for child in node.iterdir(): 45 | if not child.is_dir(): 46 | continue 47 | new_tags = tags.copy() 48 | for tag, value in tags.copy().items(): 49 | if not value: 50 | if child.name == tag: 51 | new_tags[tag] = True 52 | yield from __walk(child, new_tags.copy()) 53 | else: 54 | if child.name == tag: 55 | yield from __walk(child, new_tags.copy()) 56 | else: 57 | break 58 | -------------------------------------------------------------------------------- /hyperplane/utils/symbolics.py: -------------------------------------------------------------------------------- 1 | # symbolics.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Miscellaneous utilities for symbolic icons.""" 21 | from typing import Optional 22 | 23 | from gi.repository import Gdk, Gio, Gtk 24 | 25 | icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default()) 26 | fallback_icon = Gio.ThemedIcon.new_from_names(("text-x-generic-symbolic",)) 27 | 28 | 29 | def get_symbolic(themed_icon: Optional[Gio.ThemedIcon]) -> Gio.ThemedIcon: 30 | """Gets the symbolic icon for a file with a fallback to `text-x-generic-symbolic`.""" 31 | if not themed_icon: 32 | return fallback_icon 33 | 34 | icon_paintable = icon_theme.lookup_by_gicon( 35 | themed_icon, 1, 1, Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SYMBOLIC 36 | ) 37 | 38 | # Still add the rest of the icon names to the fallback to use for colors 39 | return Gio.ThemedIcon.new_from_names( 40 | ( 41 | icon_paintable.get_icon_name() 42 | if icon_paintable.is_symbolic() 43 | else "text-x-generic-symbolic", 44 | *themed_icon.get_names(), 45 | ) 46 | ) 47 | 48 | 49 | # pylint: disable=too-many-return-statements 50 | def get_color_for_symbolic( 51 | content_type: str, gicon: Optional[Gio.ThemedIcon] = None 52 | ) -> str: 53 | """Returns the color associated with a MIME type.""" 54 | 55 | if not content_type: 56 | return "gray" 57 | 58 | if content_type == "inode/directory": 59 | return "blue" 60 | 61 | names = gicon.get_names() 62 | 63 | # Remove the fallback name when choosing color 64 | if len(names) > 1 and names[0] == "text-x-generic-symbolic": 65 | names.pop(0) 66 | 67 | # TODO: Certificates don't have a standard mime type 68 | # TODO: I don't think addon, firmware or appliance are a thing for files 69 | # TODO: Add special cases like Flatpak 70 | 71 | detailed = { 72 | "text-html": "blue", 73 | "application-x-appliance": "gray", 74 | "application-x-addon": "green", 75 | "application-rss+xml": "orange", 76 | "application-x-firmware": "gray", 77 | "application-x-sharedlib": "green", 78 | "inode-symlink": "orange", 79 | "text-x-preview": "red", 80 | "text-x-script": "orange", 81 | "x-office-document-template": "blue", 82 | "x-office-drawing-template": "orange", 83 | "x-office-presentation-template": "red", 84 | "x-office-spreadsheet-template": "green", 85 | } 86 | 87 | if gicon and (color := detailed.get(names[0].replace("-symbolic", ""))): 88 | return color 89 | 90 | generic = { 91 | "application-x-executable": "blue", 92 | "application-x-generic": "gray", 93 | "audio-x-generic": "yellow", 94 | "font-x-generic": "purple", 95 | "image-x-generic": "purple", 96 | "package-x-generic": "orange", 97 | "text-x-generic": "gray", 98 | "video-x-generic": "red", 99 | "x-office-addressbook": "blue", 100 | "x-office-calendar": "blue", 101 | "x-office-document": "blue", 102 | "x-office-spreadsheet": "green", 103 | "x-office-presentation": "red", 104 | "x-office-drawing": "orange", 105 | } 106 | 107 | if gicon and (color := generic.get(names[-1].replace("-symbolic", ""))): 108 | return color 109 | 110 | mimes = { 111 | "application": "gray", 112 | "audio": "yellow", 113 | "font": "purple", 114 | "image": "purple", 115 | "video": "red", 116 | "text": "gray", 117 | } 118 | 119 | if color := mimes.get(content_type.split("/")[0]): 120 | return color 121 | 122 | # Fallback 123 | return "gray" 124 | -------------------------------------------------------------------------------- /hyperplane/utils/tags.py: -------------------------------------------------------------------------------- 1 | # tags.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Miscellaneous utilities for working with tags.""" 21 | from os import PathLike 22 | from pathlib import Path 23 | 24 | from gi.repository import Gtk 25 | 26 | from hyperplane import shared 27 | 28 | 29 | def update_tags(change: Gtk.FilterChange = Gtk.FilterChange.DIFFERENT) -> None: 30 | """ 31 | Writes the list of tags from `shared.tags` to disk and notifies widgets. 32 | 33 | `change` indicates whether tags were 34 | added (more strict), removed (less strict) or just reordered (different). 35 | """ 36 | (shared.home_path / ".hyperplane").write_text( 37 | "\n".join(shared.tags), encoding="utf-8" 38 | ) 39 | 40 | shared.postmaster.emit("tags-changed", change) 41 | 42 | 43 | def path_represents_tags(path: PathLike | str) -> bool: 44 | """Checks whether a given `path` represents tags or not.""" 45 | path = Path(path) 46 | 47 | if path == shared.home_path: 48 | return False 49 | 50 | if not path.is_relative_to(shared.home_path): 51 | return False 52 | 53 | return all(part in shared.tags for part in path.relative_to(shared.home_path).parts) 54 | 55 | 56 | def add_tags(*tags: str) -> None: 57 | """ 58 | Adds new tags and updates the list of tags. 59 | 60 | Assumes that tags passed as arguments are valid. 61 | """ 62 | for tag in tags: 63 | shared.tags.append(tag) 64 | update_tags(Gtk.FilterChange.MORE_STRICT) 65 | 66 | 67 | def remove_tags(*tags: str) -> None: 68 | """Removes tags and updates the list of tags.""" 69 | for tag in tags: 70 | if tag in shared.tags: 71 | shared.tags.remove(tag) 72 | update_tags(Gtk.FilterChange.LESS_STRICT) 73 | 74 | 75 | def move_tag(tag: str, up: bool) -> None: 76 | """Moves a tag up or down by one in the list of tags.""" 77 | 78 | # Moving up 79 | 80 | if up: 81 | if shared.tags[0] == tag: 82 | return 83 | 84 | index = shared.tags.index(tag) 85 | 86 | shared.tags[index], shared.tags[index - 1] = ( 87 | shared.tags[index - 1], 88 | shared.tags[index], 89 | ) 90 | update_tags() 91 | return 92 | 93 | # Moving down 94 | 95 | if shared.tags[-1] == tag: 96 | return 97 | 98 | index = shared.tags.index(tag) 99 | 100 | shared.tags[index], shared.tags[index + 1] = ( 101 | shared.tags[index + 1], 102 | shared.tags[index], 103 | ) 104 | 105 | update_tags() 106 | -------------------------------------------------------------------------------- /hyperplane/utils/thumbnail.py: -------------------------------------------------------------------------------- 1 | # thumbnail.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Utilities for working with thumbnails.""" 21 | import logging 22 | from typing import Any, Callable 23 | 24 | from gi.repository import Gdk, GdkPixbuf, Gio, GLib, GnomeDesktop 25 | 26 | from hyperplane.utils.files import get_gfile_path 27 | 28 | 29 | def generate_thumbnail( 30 | gfile: Gio.File, content_type: str, callback: Callable, *args: Any 31 | ) -> None: 32 | """ 33 | Generates a thumbnail and passes it to `callback` as a `Gdk.Texture` with any additional args. 34 | 35 | If the thumbnail generation fails, `callback` is called with None and *args. 36 | """ 37 | factory = GnomeDesktop.DesktopThumbnailFactory.new( 38 | GnomeDesktop.DesktopThumbnailSize.LARGE 39 | ) 40 | uri = gfile.get_uri() 41 | 42 | try: 43 | mtime = ( 44 | gfile.query_info( 45 | Gio.FILE_ATTRIBUTE_TIME_MODIFIED, Gio.FileQueryInfoFlags.NONE 46 | ) 47 | .get_modification_date_time() 48 | .to_unix() 49 | ) 50 | except (GLib.Error, AttributeError): 51 | callback(None, *args) 52 | return 53 | 54 | if not factory.can_thumbnail(uri, content_type, mtime): 55 | callback(None, *args) 56 | return 57 | 58 | try: 59 | thumbnail = factory.generate_thumbnail(uri, content_type) 60 | except GLib.Error as error: 61 | if error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND): 62 | # If it cannot get a path for the URI, try the target URI 63 | try: 64 | target_uri = gfile.query_info( 65 | Gio.FILE_ATTRIBUTE_STANDARD_TARGET_URI, 66 | Gio.FileQueryInfoFlags.NONE, 67 | ).get_attribute_string(Gio.FILE_ATTRIBUTE_STANDARD_TARGET_URI) 68 | 69 | if not target_uri: 70 | logging.debug("Cannot thumbnail: %s", error) 71 | callback(None, *args) 72 | return 73 | 74 | thumbnail = factory.generate_thumbnail(target_uri, content_type) 75 | except GLib.Error as new_error: 76 | logging.debug("Cannot thumbnail: %s", new_error) 77 | callback(None, *args) 78 | if not new_error.matches( 79 | Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND 80 | ): 81 | factory.create_failed_thumbnail(uri, mtime) 82 | factory.create_failed_thumbnail(target_uri, mtime) 83 | return 84 | 85 | try: 86 | # Fall back to GdkPixbuf 87 | thumbnail = GdkPixbuf.Pixbuf.new_from_file_at_size( 88 | str(get_gfile_path(gfile)), 256, 256 89 | ) 90 | except (GLib.Error, FileNotFoundError): 91 | logging.debug("Cannot thumbnail: %s", error) 92 | callback(None, *args) 93 | factory.create_failed_thumbnail(uri, mtime) 94 | return 95 | 96 | if not thumbnail: 97 | callback(None, *args) 98 | return 99 | 100 | factory.save_thumbnail(thumbnail, uri, mtime) 101 | callback(Gdk.Texture.new_for_pixbuf(thumbnail), *args) 102 | -------------------------------------------------------------------------------- /hyperplane/utils/undo.py: -------------------------------------------------------------------------------- 1 | # undo.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """Utilities for interacting with the undo queue.""" 21 | import logging 22 | from typing import Any 23 | 24 | from gi.repository import Adw, GLib 25 | 26 | from hyperplane import shared 27 | from hyperplane.utils.files import YouAreStupid, move, restore, rm 28 | 29 | 30 | def undo(obj: Any, *_args: Any) -> None: 31 | """Undoes an action in the undo queue.""" 32 | 33 | if not shared.undo_queue: 34 | return 35 | 36 | if isinstance(obj, Adw.Toast): 37 | index = obj 38 | else: 39 | index = tuple(shared.undo_queue.keys())[-1] 40 | 41 | item = shared.undo_queue[index] 42 | 43 | match item[0]: 44 | case "copy": 45 | for copy_item in item[1]: 46 | try: 47 | rm(copy_item) 48 | except FileNotFoundError: 49 | logging.debug("Cannot undo copy: File doesn't exist anymore.") 50 | 51 | case "move": 52 | for gfiles in item[1]: 53 | try: 54 | move(gfiles[1], gfiles[0]) 55 | except FileExistsError: 56 | logging.debug("Cannot undo move: File exists.") 57 | except YouAreStupid: 58 | logging.debug("Cannot undo move: Someone is being stupid.") 59 | 60 | case "rename": 61 | try: 62 | item[1].set_display_name(item[2]) 63 | except GLib.Error as error: 64 | logging.debug("Cannot undo rename: %s", error) 65 | 66 | case "trash": 67 | for trash_item in item[1]: 68 | restore(*trash_item) 69 | 70 | if isinstance(index, Adw.Toast): 71 | index.dismiss() 72 | 73 | shared.undo_queue.popitem() 74 | -------------------------------------------------------------------------------- /hyperplane/volumes_box.py: -------------------------------------------------------------------------------- 1 | # volumes_box.py 2 | # 3 | # Copyright 2023-2024 kramo 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with this program. If not, see . 17 | # 18 | # SPDX-License-Identifier: GPL-3.0-or-later 19 | 20 | """ 21 | A self-updating `GtkListBox` (wrapped in an `AdwBin`) of mountable volumes. 22 | 23 | To be used in a sidebar. 24 | """ 25 | import logging 26 | from itertools import count 27 | from typing import Any, Optional 28 | 29 | from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk 30 | 31 | from hyperplane import shared 32 | from hyperplane.editable_row import HypEditableRow 33 | from hyperplane.navigation_bin import HypNavigationBin 34 | 35 | 36 | # TODO: Subclass HypHoverPageOpener 37 | @Gtk.Template(resource_path=shared.PREFIX + "/gtk/volumes-box.ui") 38 | class HypVolumesBox(Adw.Bin): 39 | """ 40 | A self-updating `GtkListBox` (wrapped in an `AdwBin`) of mountable volumes. 41 | 42 | To be used in a sidebar. 43 | """ 44 | 45 | __gtype_name__ = "HypVolumesBox" 46 | 47 | list_box: Gtk.ListBox = Gtk.Template.Child() 48 | right_click_menu: Gtk.PopoverMenu = Gtk.Template.Child() 49 | 50 | volume_monitor = Gio.VolumeMonitor.get() 51 | 52 | _visible_rows: int 53 | _has_any: bool 54 | 55 | def __init__(self, **kwargs) -> None: 56 | super().__init__(**kwargs) 57 | self.visible_rows = 0 58 | self.has_any = False 59 | 60 | self.actions = {} 61 | self.rows = {} 62 | self.build() 63 | 64 | self.volume_monitor.connect("volume-changed", self.__volume_changed) 65 | self.volume_monitor.connect( 66 | "volume-added", lambda _monitor, volume: self.add_volume(volume) 67 | ) 68 | self.volume_monitor.connect( 69 | "volume-removed", lambda _monitor, volume: self.remove_volume(volume) 70 | ) 71 | 72 | self.list_box.connect("row-activated", lambda _box, row: self.actions[row]()) 73 | 74 | def build(self) -> None: 75 | """(Re)builds the sidebar. This is called automatically on `__init__`.""" 76 | self.list_box.remove_all() 77 | 78 | for volume in self.volume_monitor.get_volumes(): 79 | self.add_volume(volume) 80 | 81 | def add_volume(self, volume: Gio.Volume) -> None: 82 | """ 83 | Adds `volume` to the sidebar. 84 | 85 | This is done automatically for any new volumes 86 | so in most cases, calling this should not be necessary. 87 | """ 88 | 89 | row = HypEditableRow( 90 | identifier=f"volume_{volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_UUID)}" 91 | ) 92 | 93 | def set_visible_rows(row: HypEditableRow, *_args: Any) -> None: 94 | # Add 1 if the widget is visible, subtract 1 otherwise 95 | self.visible_rows += 2 * int(row.get_visible()) - 1 96 | 97 | self.visible_rows += row.get_visible() 98 | row.connect("notify::visible", set_visible_rows) 99 | 100 | row.title = volume.get_name() 101 | row.image.set_from_gicon(volume.get_symbolic_icon()) 102 | 103 | if volume.can_eject(): 104 | eject_button = Gtk.Button( 105 | icon_name="media-eject-symbolic", 106 | valign=Gtk.Align.CENTER, 107 | halign=Gtk.Align.END, 108 | hexpand=True, 109 | ) 110 | eject_button.add_css_class("flat") 111 | eject_button.add_css_class("sidebar-button") 112 | 113 | def eject_with_operation_finish( 114 | volume: Gio.Volume, result: Gio.AsyncResult 115 | ) -> None: 116 | try: 117 | volume.eject_with_operation_finish(result) 118 | except GLib.Error as error: 119 | logging.error('Unable to eject "%s": %s', volume.get_name(), error) 120 | return 121 | 122 | def do_eject(volume: Gio.Volume) -> None: 123 | volume.eject_with_operation( 124 | Gio.MountUnmountFlags.NONE, 125 | Gtk.MountOperation.new(), 126 | callback=eject_with_operation_finish, 127 | ) 128 | 129 | def eject() -> None: 130 | # TODO: This sucks so much 131 | 132 | if not (mount := volume.get_mount()): 133 | return 134 | 135 | location = mount.get_default_location() 136 | 137 | # What if you wanted to itertools.chain but Alice said: 138 | # Return Value 139 | # Type: GtkSelectionModel 140 | store = Gio.ListStore.new(Gtk.SelectionModel) 141 | for model in ( 142 | window.tab_view.get_pages() for window in shared.app.get_windows() 143 | ): 144 | store.append(model) 145 | tabs = Gtk.FlattenListModel.new(store) 146 | 147 | for tab_index in count(): 148 | if not (tab := tabs.get_item(tab_index)): 149 | break 150 | 151 | nav_bin = tab.get_child() 152 | stack = nav_bin.view.get_navigation_stack() 153 | 154 | # This nesting is hell. 155 | for nav_index in count(): 156 | if not (page := stack.get_item(nav_index)): 157 | break 158 | 159 | if not page.gfile: 160 | continue 161 | 162 | if not ( 163 | location.get_relative_path(page.gfile) 164 | or location.get_uri() == page.gfile.get_uri() 165 | ): 166 | continue 167 | 168 | tab_view = page.get_root().tab_view 169 | 170 | tab_view.insert( 171 | navigation_bin := HypNavigationBin(shared.home), 172 | tab_view.get_page_position(tab), 173 | ).set_title(navigation_bin.view.get_visible_page().get_title()) 174 | tab_view.close_page(tab) 175 | break 176 | 177 | # Why the fuck does this work but not timeout_add on the 178 | # fucking method itself????????? 179 | 180 | # Add half a second of delay to make sure all fds are closed by Hyperplane 181 | GLib.timeout_add(500, do_eject, volume) 182 | 183 | eject_button.connect("clicked", lambda *_: eject()) 184 | 185 | row.box.insert_child_after(eject_button, row.label) 186 | 187 | self.rows[volume] = row 188 | self.__volume_changed(None, volume) 189 | 190 | (right_click := Gtk.GestureClick(button=Gdk.BUTTON_SECONDARY)).connect( 191 | "pressed", self.__right_click, volume 192 | ) 193 | 194 | (middle_click := Gtk.GestureClick(button=Gdk.BUTTON_MIDDLE)).connect( 195 | "pressed", self.__middle_click, volume 196 | ) 197 | 198 | row.add_controller(right_click) 199 | row.add_controller(middle_click) 200 | 201 | (self.list_box.prepend if volume.can_eject() else self.list_box.append)(row) 202 | 203 | def remove_volume(self, volume: Gio.Volume) -> None: 204 | """ 205 | Removes `volume` from the sidebar. 206 | 207 | This is done automatically for any removed volumes 208 | so in most cases, calling this should not be necessary. 209 | """ 210 | 211 | # If the row is not in the sidebar 212 | if not (row := self.rows.get(volume)): 213 | return 214 | 215 | self.visible_rows -= int(row.get_visible()) 216 | 217 | self.list_box.remove(row) 218 | self.actions.pop(row) 219 | 220 | @property 221 | def visible_rows(self) -> int: 222 | """ 223 | The number of rows currently visible. 224 | 225 | This is used for showing/hiding the separator via `has-any`. 226 | """ 227 | return self._visible_rows 228 | 229 | @visible_rows.setter 230 | def visible_rows(self, n: int) -> None: 231 | self._visible_rows = n 232 | self.has_any = bool(self.visible_rows) 233 | 234 | @GObject.Property(type=bool, default=False) 235 | def has_any(self) -> str: 236 | """Whether the row is actually editable.""" 237 | return self._has_any 238 | 239 | @has_any.setter 240 | def set_has_any(self, has_any: bool) -> None: 241 | self._has_any = has_any 242 | 243 | def __right_click( 244 | self, 245 | gesture: Gtk.GestureClick, 246 | _n: int, 247 | x: float, 248 | y: float, 249 | volume: Gio.Volume, 250 | ) -> None: 251 | # Mount if not mounted 252 | # TODO: Maybe it should still display the menu afterwards 253 | # instead of opening the drive 254 | if not (mount := volume.get_mount()): 255 | self.actions[self.rows[volume]]() 256 | return 257 | 258 | shared.right_clicked_file = mount.get_default_location() 259 | 260 | self.right_click_menu.unparent() 261 | self.right_click_menu.set_parent(gesture.get_widget()) 262 | rectangle = Gdk.Rectangle() 263 | rectangle.x, rectangle.y, rectangle.width, rectangle.height = x, y, 0, 0 264 | self.right_click_menu.set_pointing_to(rectangle) 265 | self.right_click_menu.popup() 266 | 267 | def __middle_click( 268 | self, 269 | _gesture: Gtk.GestureClick, 270 | _n: int, 271 | _x: float, 272 | _y: float, 273 | volume: Gio.Volume, 274 | ) -> None: 275 | # Mount if not mounted 276 | # TODO: Maybe it should still open in a new tab afterwards 277 | if not (mount := volume.get_mount()): 278 | self.actions[self.rows[volume]]() 279 | return 280 | 281 | self.get_root().new_tab(mount.get_default_location()) 282 | 283 | def __volume_changed( 284 | self, 285 | _monitor: Optional[Gio.VolumeMonitor], 286 | volume: Gio.Volume, 287 | ) -> None: 288 | # If the row is not in the sidebar 289 | if not (row := self.rows.get(volume)): 290 | return 291 | 292 | if mount := volume.get_mount(): 293 | self.actions[row] = lambda *_, mount=mount: self.get_root().new_page( 294 | mount.get_default_location() 295 | ) 296 | else: 297 | self.actions[row] = lambda *_, volume=volume, row=row: volume.mount( 298 | Gio.MountMountFlags.NONE, 299 | Gtk.MountOperation.new(self.get_root()), 300 | callback=self.__mount_finish, 301 | ) 302 | 303 | def __mount_finish(self, volume: Gio.Volume, result: Gio.AsyncResult) -> None: 304 | try: 305 | volume.mount_finish(result) 306 | except GLib.Error as error: 307 | if error.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED): 308 | # Try the activation root. 309 | # This works for MTP volumes, not sure if I should be doing it though 310 | if root := volume.get_activation_root(): 311 | self.get_root().new_page(root) 312 | else: 313 | logging.error( 314 | 'Unable to mount volume "%s": %s', volume.get_name(), error 315 | ) 316 | return 317 | 318 | self.get_root().new_page(volume.get_mount().get_default_location()) 319 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project('hyperplane', 2 | version: '0.5.1-beta', 3 | meson_version: '>= 0.62.0', 4 | default_options: [ 'warning_level=2', 'werror=false', ], 5 | ) 6 | 7 | i18n = import('i18n') 8 | gnome = import('gnome') 9 | python = import('python') 10 | 11 | py_installation = python.find_installation('python3') 12 | 13 | python_dir = join_paths(get_option('prefix'), py_installation.get_install_dir()) 14 | pkgdatadir = get_option('prefix') / get_option('datadir') / meson.project_name() 15 | 16 | profile = get_option('profile') 17 | if profile == 'development' 18 | app_id = 'page.kramo.Hyperplane.Devel' 19 | prefix = '/page/kramo/Hyperplane/Devel' 20 | elif profile == 'release' 21 | app_id = 'page.kramo.Hyperplane' 22 | prefix = '/page/kramo/Hyperplane' 23 | endif 24 | 25 | conf = configuration_data() 26 | conf.set('PYTHON', python.find_installation('python3').full_path()) 27 | conf.set('VERSION', meson.project_version()) 28 | conf.set('APP_ID', app_id) 29 | conf.set('PREFIX', prefix) 30 | conf.set('PROFILE', profile) 31 | conf.set('localedir', get_option('prefix') / get_option('localedir')) 32 | conf.set('pkgdatadir', pkgdatadir) 33 | conf.set('bindir', get_option('bindir')) 34 | conf.set('prefix', get_option('prefix')) 35 | 36 | subdir('data') 37 | subdir('hyperplane') 38 | subdir('po') 39 | 40 | gnome.post_install( 41 | glib_compile_schemas: true, 42 | gtk_update_icon_cache: true, 43 | update_desktop_database: true, 44 | ) 45 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'release', 6 | 'development', 7 | ], 8 | value: 'release' 9 | ) -------------------------------------------------------------------------------- /page.kramo.Hyperplane.Devel.json: -------------------------------------------------------------------------------- 1 | { 2 | "id" : "page.kramo.Hyperplane.Devel", 3 | "runtime" : "org.gnome.Platform", 4 | "runtime-version" : "master", 5 | "sdk" : "org.gnome.Sdk", 6 | "command" : "hyperplane", 7 | "finish-args" : [ 8 | "--share=ipc", 9 | "--socket=fallback-x11", 10 | "--device=dri", 11 | "--socket=wayland", 12 | "--filesystem=host", 13 | "--talk-name=org.gtk.vfs.*", 14 | "--talk-name=org.freedesktop.Flatpak", 15 | "--filesystem=xdg-run/gvfsd", 16 | "--filesystem=xdg-cache/thumbnails", 17 | "--own-name=org.freedesktop.FileManager1" 18 | ], 19 | "cleanup" : [ 20 | "/include", 21 | "/lib/pkgconfig", 22 | "/man", 23 | "/share/doc", 24 | "/share/gtk-doc", 25 | "/share/man", 26 | "/share/pkgconfig", 27 | "*.la", 28 | "*.a" 29 | ], 30 | "modules" : [ 31 | { 32 | "name": "libportal", 33 | "buildsystem": "meson", 34 | "config-opts": [ 35 | "-Dtests=false", 36 | "-Dbackend-gtk3=disabled", 37 | "-Dbackend-gtk4=enabled", 38 | "-Dbackend-qt5=disabled", 39 | "-Ddocs=false" 40 | ], 41 | "sources": [ 42 | { 43 | "type": "archive", 44 | "url": "https://github.com/flatpak/libportal/releases/download/0.7.1/libportal-0.7.1.tar.xz", 45 | "sha256": "297b90b263fad22190a26b8c7e8ea938fe6b18fb936265e588927179920d3805" 46 | } 47 | ] 48 | }, 49 | { 50 | "name": "gnome-desktop", 51 | "buildsystem": "meson", 52 | "config-opts": [ 53 | "-Ddebug_tools=false", 54 | "-Ddesktop_docs=false", 55 | "-Dudev=disabled" 56 | ], 57 | "sources": [ 58 | { 59 | "type": "archive", 60 | "url": "https://download.gnome.org/sources/gnome-desktop/44/gnome-desktop-44.0.tar.xz", 61 | "sha256": "42c773745d84ba14bc1cf1c4c6f4606148803a5cd337941c63964795f3c59d42", 62 | "x-checker-data": { 63 | "type": "gnome", 64 | "name": "gnome-desktop" 65 | } 66 | } 67 | ] 68 | }, 69 | { 70 | "name": "totem-pl-parser", 71 | "buildsystem": "meson", 72 | "sources": [ 73 | { 74 | "type": "archive", 75 | "url": "https://download.gnome.org/sources/totem-pl-parser/3.26/totem-pl-parser-3.26.6.tar.xz", 76 | "sha256": "c0df0f68d5cf9d7da43c81c7f13f11158358368f98c22d47722f3bd04bd3ac1c", 77 | "x-checker-data": { 78 | "type": "gnome", 79 | "name": "totem-pl-parser" 80 | } 81 | } 82 | ] 83 | }, 84 | { 85 | "name": "totem-video-thumbnailer", 86 | "buildsystem": "meson", 87 | "sources": [ 88 | { 89 | "type": "git", 90 | "url": "https://gitlab.gnome.org/GNOME/totem-video-thumbnailer.git", 91 | "commit": "78ddf6c113d439d8ad9ff1453cf433592fabe9fd" 92 | } 93 | ] 94 | }, 95 | { 96 | "name" : "blueprint-compiler", 97 | "buildsystem" : "meson", 98 | "sources" : [ 99 | { 100 | "type" : "git", 101 | "url" : "https://gitlab.gnome.org/jwestman/blueprint-compiler", 102 | "tag" : "v0.16.0" 103 | } 104 | ], 105 | "cleanup" : [ 106 | "*" 107 | ] 108 | }, 109 | { 110 | "name" : "hyperplane", 111 | "builddir" : true, 112 | "buildsystem" : "meson", 113 | "run-tests" : true, 114 | "config-opts": [ 115 | "-Dprofile=development" 116 | ], 117 | "sources" : [ 118 | { 119 | "type" : "dir", 120 | "path" : "." 121 | } 122 | ] 123 | } 124 | ] 125 | } 126 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kra-mo/hyperplane/a63e21d6691fe96607208dc7ae23f7107f959cc5/po/LINGUAS -------------------------------------------------------------------------------- /po/POTFILES: -------------------------------------------------------------------------------- 1 | data/page.kramo.Hyperplane.desktop.in 2 | data/page.kramo.Hyperplane.metainfo.xml.in 3 | data/page.kramo.Hyperplane.gschema.xml.in 4 | hyperplane/main.py 5 | hyperplane/window.py 6 | hyperplane/gtk/window.ui 7 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext('hyperplane', preset: 'glib') 2 | -------------------------------------------------------------------------------- /subprojects/blueprint-compiler.wrap: -------------------------------------------------------------------------------- 1 | [wrap-git] 2 | directory = blueprint-compiler 3 | url = https://gitlab.gnome.org/jwestman/blueprint-compiler.git 4 | revision = v0.16.0 5 | depth = 1 6 | 7 | [provide] 8 | program_names = blueprint-compiler 9 | --------------------------------------------------------------------------------