├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build-aux └── dist-vendor.sh ├── data ├── dev.vlinkz.NixosConfEditor.desktop.in.in ├── dev.vlinkz.NixosConfEditor.gschema.xml.in ├── dev.vlinkz.NixosConfEditor.metainfo.xml.in.in ├── dev.vlinkz.NixosConfEditor.policy.in.in ├── icons │ ├── dev.vlinkz.NixosConfEditor-symbolic.svg │ ├── dev.vlinkz.NixosConfEditor.Devel.svg │ ├── dev.vlinkz.NixosConfEditor.svg │ ├── dev.vlinkz.NixosConfEditor.template.svg │ └── meson.build ├── meson.build └── screenshots │ ├── invaliddark.png │ ├── invalidlight.png │ ├── listviewdark.png │ ├── listviewlight.png │ ├── multiwindowdark.png │ ├── multiwindowlight.png │ ├── optiondark.png │ ├── optiondark2.png │ ├── optionlight.png │ ├── optionlight2.png │ ├── rebuilddark.png │ ├── rebuildlight.png │ ├── searchdark.png │ └── searchlight.png ├── default.nix ├── flake.lock ├── flake.nix ├── meson.build ├── meson_options.txt ├── nce-helper ├── .gitignore ├── Cargo.lock ├── Cargo.toml └── src │ ├── main.rs │ └── meson.build ├── packages └── nixos-conf-editor │ └── default.nix ├── po ├── LINGUAS ├── POTFILES.in └── meson.build ├── shells └── default │ └── default.nix └── src ├── config.rs.in ├── lib.rs ├── main.rs ├── meson.build ├── parse ├── config.rs ├── mod.rs ├── options.rs └── preferences.rs └── ui ├── about.rs ├── mod.rs ├── nameentry.rs ├── optionpage.rs ├── preferencespage.rs ├── quitdialog.rs ├── rebuild.rs ├── savechecking.rs ├── searchentry.rs ├── searchfactory.rs ├── searchpage.rs ├── treefactory.rs ├── welcome.rs ├── window.rs └── windowloading.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | *.json 3 | *.br 4 | crf 5 | .vscode 6 | shell.nix 7 | /build 8 | TODO 9 | /src/config.rs 10 | /result 11 | result 12 | /.direnv 13 | .envrc -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nixos-conf-editor" 3 | version = "0.1.2" 4 | edition = "2021" 5 | default-run = "nixos-conf-editor" 6 | 7 | [dependencies] 8 | relm4 = { version = "0.5.1", features = ["libadwaita"] } 9 | relm4-components = { package = "relm4-components", version = "0.5.1"} 10 | adw = { package = "libadwaita", version = "0.2", features = ["v1_2", "gtk_v4_6"] } 11 | gtk = { package = "gtk4", version = "0.5", features = ["v4_6"] } 12 | sourceview5 = { version = "0.5", features = ["v5_4"] } 13 | vte = { package = "vte4", version = "0.5" } 14 | tracker = "0.2" 15 | tokio = { version = "1.24", features = ["rt", "macros", "time", "rt-multi-thread", "sync"] } 16 | 17 | serde_json = "1.0" 18 | serde = { version = "1.0", features = ["derive"] } 19 | ijson = "0.1" 20 | 21 | nix-editor = "0.3.0" 22 | nix-data = "0.0.3" 23 | 24 | anyhow = "1.0" 25 | 26 | html2pango = "0.5" 27 | pandoc = "0.8" 28 | 29 | log = "0.4" 30 | pretty_env_logger = "0.4" 31 | gettext-rs = { version = "0.7", features = ["gettext-system"] } 32 | 33 | [workspace] 34 | members = [".", "nce-helper"] 35 | default-members = [".", "nce-helper"] 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | NixOS Configuration Editor 6 | === 7 | 8 | [![Built with Nix][builtwithnix badge]][builtwithnix] 9 | [![License: GPLv3][GPLv3 badge]][GPLv3] 10 | [![Chat on Matrix][matrix badge]][matrix] 11 | [![Chat on Discord][discord badge]][discord] 12 | 13 | A simple NixOS configuration editor application built with [libadwaita](https://gitlab.gnome.org/GNOME/libadwaita), [GTK4](https://www.gtk.org/), and [Relm4](https://relm4.org/). The goal of this project is to provide a simple graphical tool for modifying and managing desktop NixOS configurations. 14 | 15 | 16 | 17 | 18 |
19 | 20 | ## NixOS Flakes Installation 21 | `flake.nix` 22 | ```nix 23 | { 24 | inputs = { 25 | # other inputs 26 | nixos-conf-editor.url = "github:snowfallorg/nixos-conf-editor"; 27 | # rest of flake.nix 28 | ``` 29 | 30 | `configuration.nix` 31 | ```nix 32 | environment.systemPackages = with pkgs; [ 33 | inputs.nixos-conf-editor.packages.${system}.nixos-conf-editor 34 | # rest of your packages 35 | ]; 36 | ``` 37 | 38 | ## NixOS Installation 39 | 40 | Head of `configuration.nix` 41 | 42 | if you are on unstable channel or any version after 22.11: 43 | ```nix 44 | { config, pkgs, lib, ... }: 45 | let 46 | nixos-conf-editor = import (pkgs.fetchFromGitHub { 47 | owner = "snowfallorg"; 48 | repo = "nixos-conf-editor"; 49 | rev = "0.1.2"; 50 | sha256 = "sha256-/ktLbmF1pU3vFHeGooDYswJipNE2YINm0WpF9Wd1gw8="; 51 | }) {}; 52 | in 53 | ``` 54 | Packages: 55 | 56 | ```nix 57 | environment.systemPackages = 58 | with pkgs; [ 59 | nixos-conf-editor 60 | # rest of your packages 61 | ]; 62 | ``` 63 | For any other method of installation, when rebuilding you will be prompted to authenticate twice in a row 64 | 65 | ## 'nix profile' installation 66 | ```bash 67 | nix profile install github:snowfallorg/nixos-conf-editor 68 | ``` 69 | 70 | ## 'nix-env' Installation 71 | 72 | ```bash 73 | git clone https://github.com/snowfallorg/nixos-conf-editor 74 | nix-env -f nixos-conf-editor -i nixos-conf-editor 75 | ``` 76 | 77 | ## Single run on an flakes enabled system: 78 | ```bash 79 | nix run github:snowfallorg/nixos-conf-editor 80 | ``` 81 | 82 | ## Single run on non-flakes enabled system: 83 | ```bash 84 | nix --extra-experimental-features "nix-command flakes" run github:snowfallorg/nixos-conf-editor 85 | ``` 86 | 87 | ## Debugging 88 | 89 | ```bash 90 | RUST_LOG=nixos_conf_editor=trace nixos-conf-editor 91 | ``` 92 | 93 | # Screenshots 94 | 95 |

96 | 97 | 98 |

99 | 100 |

101 | 102 | 103 |

104 | 105 |

106 | 107 | 108 |

109 | 110 |

111 | 112 | 113 |

114 | 115 |

116 | 117 | 118 |

119 | 120 | ## Licenses 121 | 122 | The icons in [data/icons](data/icons/) contains assets from the [NixOS logo](https://github.com/NixOS/nixos-artwork/tree/master/logo) and are licensed under a [CC-BY license](https://creativecommons.org/licenses/by/4.0/). 123 | 124 | [builtwithnix badge]: https://img.shields.io/badge/Built%20With-Nix-41439A?style=for-the-badge&logo=nixos&logoColor=white 125 | [builtwithnix]: https://builtwithnix.org/ 126 | [GPLv3 badge]: https://img.shields.io/badge/License-GPLv3-blue.svg?style=for-the-badge 127 | [GPLv3]: https://opensource.org/licenses/GPL-3.0 128 | [matrix badge]: https://img.shields.io/badge/matrix-join%20chat-0cbc8c?style=for-the-badge&logo=matrix&logoColor=white 129 | [matrix]: https://matrix.to/#/#snowflakeos:matrix.org 130 | [discord badge]: https://img.shields.io/discord/1021080090676842506?color=7289da&label=Discord&logo=discord&logoColor=ffffff&style=for-the-badge 131 | [discord]: https://discord.gg/6rWNMmdkgT 132 | -------------------------------------------------------------------------------- /build-aux/dist-vendor.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | export SOURCE_ROOT="$1" 3 | export DIST="$2" 4 | 5 | cd "$SOURCE_ROOT" 6 | mkdir "$DIST"/.cargo 7 | cargo vendor | sed 's/^directory = ".*"/directory = "vendor"/g' > $DIST/.cargo/config 8 | # Move vendor into dist tarball directory 9 | mv vendor "$DIST" 10 | -------------------------------------------------------------------------------- /data/dev.vlinkz.NixosConfEditor.desktop.in.in: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Configuration Editor 3 | Comment=Configure your NixOS system 4 | Type=Application 5 | Exec=nixos-conf-editor 6 | Terminal=false 7 | Categories=Settings;System;Utility; 8 | # Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! 9 | Keywords=Nix;Nixos;nix;nixos;configuration;settings;registry; 10 | # Translators: Do NOT translate or transliterate this text (this is an icon file name)! 11 | Icon=@icon@ 12 | StartupNotify=true 13 | -------------------------------------------------------------------------------- /data/dev.vlinkz.NixosConfEditor.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /data/dev.vlinkz.NixosConfEditor.metainfo.xml.in.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | @app-id@ 4 | CC0-1.0 5 | GPL-3.0-or-later 6 | NixOS Configuration Editor 7 | A simple application to manage your NixOS system configuration. 8 | 9 |

A simple graphical tool for modifying and managing desktop NixOS configurations built with libadwaita, GTK4, and Relm4.

10 |
11 | 12 | 13 | https://raw.githubusercontent.com/vlinkz/nixos-conf-editor/main/data/screenshots/listviewlight.png 14 | Main window 15 | 16 | 17 | https://raw.githubusercontent.com/vlinkz/nixos-conf-editor/main/data/screenshots/optionlight2.png 18 | Option Selection 19 | 20 | 21 | https://github.com/vlinkz/nixos-conf-editor 22 | https://github.com/vlinkz/nixos-conf-editor/issues 23 | Victor Fuentes 24 | vmfuentes64@gmail.com 25 | @gettext-package@ 26 | @app-id@.desktop 27 |
28 | -------------------------------------------------------------------------------- /data/dev.vlinkz.NixosConfEditor.policy.in.in: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | Victor Fuentes 6 | https://github.com/vlinkz 7 | 8 | Give NixOS Configuration Editor root access 9 | Authentication is required modify the NixOS system configuration 10 | 11 | no 12 | no 13 | auth_admin_keep 14 | 15 | @pkglibexecdir@/nce-helper 16 | 17 | 18 | -------------------------------------------------------------------------------- /data/icons/dev.vlinkz.NixosConfEditor-symbolic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /data/icons/dev.vlinkz.NixosConfEditor.Devel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 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 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /data/icons/dev.vlinkz.NixosConfEditor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /data/icons/meson.build: -------------------------------------------------------------------------------- 1 | install_data( 2 | '@0@.svg'.format(application_id), 3 | install_dir: iconsdir / 'hicolor' / 'scalable' / 'apps' 4 | ) 5 | 6 | install_data( 7 | '@0@-symbolic.svg'.format(base_id), 8 | install_dir: iconsdir / 'hicolor' / 'symbolic' / 'apps', 9 | rename: '@0@-symbolic.svg'.format(application_id) 10 | ) 11 | -------------------------------------------------------------------------------- /data/meson.build: -------------------------------------------------------------------------------- 1 | subdir('icons') 2 | # Desktop file 3 | desktop_conf = configuration_data() 4 | desktop_conf.set('icon', application_id) 5 | desktop_file = i18n.merge_file( 6 | type: 'desktop', 7 | input: configure_file( 8 | input: '@0@.desktop.in.in'.format(base_id), 9 | output: '@BASENAME@', 10 | configuration: desktop_conf 11 | ), 12 | output: '@0@.desktop'.format(application_id), 13 | po_dir: podir, 14 | install: true, 15 | install_dir: datadir / 'applications' 16 | ) 17 | # Validate Desktop file 18 | if desktop_file_validate.found() 19 | test( 20 | 'validate-desktop', 21 | desktop_file_validate, 22 | args: [ 23 | desktop_file.full_path() 24 | ], 25 | depends: desktop_file, 26 | ) 27 | endif 28 | 29 | # Appdata 30 | appdata_conf = configuration_data() 31 | appdata_conf.set('app-id', application_id) 32 | appdata_conf.set('gettext-package', gettext_package) 33 | appdata_file = i18n.merge_file( 34 | input: configure_file( 35 | input: '@0@.metainfo.xml.in.in'.format(base_id), 36 | output: '@BASENAME@', 37 | configuration: appdata_conf 38 | ), 39 | output: '@0@.metainfo.xml'.format(application_id), 40 | po_dir: podir, 41 | install: true, 42 | install_dir: datadir / 'metainfo' 43 | ) 44 | 45 | # Validate Appdata 46 | if appstream_util.found() 47 | test( 48 | 'validate-appdata', appstream_util, 49 | args: [ 50 | 'validate', '--nonet', appdata_file.full_path() 51 | ], 52 | depends: appdata_file, 53 | ) 54 | endif 55 | 56 | # Policy file 57 | dataconf = configuration_data() 58 | dataconf.set('pkglibexecdir', 59 | libexecdir 60 | ) 61 | i18n.merge_file( 62 | input : configure_file( 63 | configuration: dataconf, 64 | input : '@0@.policy.in.in'.format(base_id), 65 | output: '@BASENAME@' 66 | ), 67 | output: '@0@.policy'.format(base_id), 68 | po_dir: podir, 69 | install: true, 70 | install_dir: datadir / 'polkit-1' / 'actions' 71 | ) 72 | 73 | # GSchema 74 | gschema_conf = configuration_data() 75 | gschema_conf.set('app-id', application_id) 76 | gschema_conf.set('gettext-package', gettext_package) 77 | configure_file( 78 | input: '@0@.gschema.xml.in'.format(base_id), 79 | output: '@0@.gschema.xml'.format(application_id), 80 | configuration: gschema_conf, 81 | install: true, 82 | install_dir: datadir / 'glib-2.0' / 'schemas' 83 | ) 84 | 85 | # Validate GSchema 86 | if glib_compile_schemas.found() 87 | test( 88 | 'validate-gschema', glib_compile_schemas, 89 | args: [ 90 | '--strict', '--dry-run', meson.current_build_dir() 91 | ], 92 | ) 93 | endif 94 | -------------------------------------------------------------------------------- /data/screenshots/invaliddark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/invaliddark.png -------------------------------------------------------------------------------- /data/screenshots/invalidlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/invalidlight.png -------------------------------------------------------------------------------- /data/screenshots/listviewdark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/listviewdark.png -------------------------------------------------------------------------------- /data/screenshots/listviewlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/listviewlight.png -------------------------------------------------------------------------------- /data/screenshots/multiwindowdark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/multiwindowdark.png -------------------------------------------------------------------------------- /data/screenshots/multiwindowlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/multiwindowlight.png -------------------------------------------------------------------------------- /data/screenshots/optiondark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/optiondark.png -------------------------------------------------------------------------------- /data/screenshots/optiondark2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/optiondark2.png -------------------------------------------------------------------------------- /data/screenshots/optionlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/optionlight.png -------------------------------------------------------------------------------- /data/screenshots/optionlight2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/optionlight2.png -------------------------------------------------------------------------------- /data/screenshots/rebuilddark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/rebuilddark.png -------------------------------------------------------------------------------- /data/screenshots/rebuildlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/rebuildlight.png -------------------------------------------------------------------------------- /data/screenshots/searchdark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/searchdark.png -------------------------------------------------------------------------------- /data/screenshots/searchlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/data/screenshots/searchlight.png -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import 2 | ( 3 | let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in 4 | fetchTarball { 5 | url = lock.nodes.flake-compat.locked.url or "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 6 | sha256 = lock.nodes.flake-compat.locked.narHash; 7 | } 8 | ) 9 | { src = ./.; } 10 | ).defaultNix 11 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-compat": { 4 | "locked": { 5 | "lastModified": 1696426674, 6 | "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", 7 | "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", 8 | "revCount": 57, 9 | "type": "tarball", 10 | "url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.0.1/018afb31-abd1-7bff-a5e4-cff7e18efb7a/source.tar.gz" 11 | }, 12 | "original": { 13 | "type": "tarball", 14 | "url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz" 15 | } 16 | }, 17 | "flake-compat_2": { 18 | "flake": false, 19 | "locked": { 20 | "lastModified": 1650374568, 21 | "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", 22 | "owner": "edolstra", 23 | "repo": "flake-compat", 24 | "rev": "b4a34015c698c7793d592d66adbab377907a2be8", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "edolstra", 29 | "repo": "flake-compat", 30 | "type": "github" 31 | } 32 | }, 33 | "flake-utils": { 34 | "inputs": { 35 | "systems": "systems" 36 | }, 37 | "locked": { 38 | "lastModified": 1694529238, 39 | "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 40 | "owner": "numtide", 41 | "repo": "flake-utils", 42 | "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "numtide", 47 | "repo": "flake-utils", 48 | "type": "github" 49 | } 50 | }, 51 | "flake-utils-plus": { 52 | "inputs": { 53 | "flake-utils": "flake-utils" 54 | }, 55 | "locked": { 56 | "lastModified": 1696331477, 57 | "narHash": "sha256-YkbRa/1wQWdWkVJ01JvV+75KIdM37UErqKgTf0L54Fk=", 58 | "owner": "gytis-ivaskevicius", 59 | "repo": "flake-utils-plus", 60 | "rev": "bfc53579db89de750b25b0c5e7af299e0c06d7d3", 61 | "type": "github" 62 | }, 63 | "original": { 64 | "owner": "gytis-ivaskevicius", 65 | "repo": "flake-utils-plus", 66 | "type": "github" 67 | } 68 | }, 69 | "nixpkgs": { 70 | "locked": { 71 | "lastModified": 1698318101, 72 | "narHash": "sha256-gUihHt3yPD7bVqg+k/UVHgngyaJ3DMEBchbymBMvK1E=", 73 | "owner": "nixos", 74 | "repo": "nixpkgs", 75 | "rev": "63678e9f3d3afecfeafa0acead6239cdb447574c", 76 | "type": "github" 77 | }, 78 | "original": { 79 | "owner": "nixos", 80 | "ref": "nixos-unstable", 81 | "repo": "nixpkgs", 82 | "type": "github" 83 | } 84 | }, 85 | "root": { 86 | "inputs": { 87 | "flake-compat": "flake-compat", 88 | "nixpkgs": "nixpkgs", 89 | "snowfall-lib": "snowfall-lib" 90 | } 91 | }, 92 | "snowfall-lib": { 93 | "inputs": { 94 | "flake-compat": "flake-compat_2", 95 | "flake-utils-plus": "flake-utils-plus", 96 | "nixpkgs": [ 97 | "nixpkgs" 98 | ] 99 | }, 100 | "locked": { 101 | "lastModified": 1696432959, 102 | "narHash": "sha256-oJQZv2MYyJaVyVJY5IeevzqpGvMGKu5pZcCCJvb+xjc=", 103 | "owner": "snowfallorg", 104 | "repo": "lib", 105 | "rev": "92803a029b5314d4436a8d9311d8707b71d9f0b6", 106 | "type": "github" 107 | }, 108 | "original": { 109 | "owner": "snowfallorg", 110 | "repo": "lib", 111 | "type": "github" 112 | } 113 | }, 114 | "systems": { 115 | "locked": { 116 | "lastModified": 1681028828, 117 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 118 | "owner": "nix-systems", 119 | "repo": "default", 120 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 121 | "type": "github" 122 | }, 123 | "original": { 124 | "owner": "nix-systems", 125 | "repo": "default", 126 | "type": "github" 127 | } 128 | } 129 | }, 130 | "root": "root", 131 | "version": 7 132 | } 133 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 4 | snowfall-lib = { 5 | url = "github:snowfallorg/lib"; 6 | inputs.nixpkgs.follows = "nixpkgs"; 7 | }; 8 | flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"; 9 | }; 10 | 11 | outputs = inputs: 12 | inputs.snowfall-lib.mkFlake { 13 | inherit inputs; 14 | alias.packages.default = "nixos-conf-editor"; 15 | src = ./.; 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | project( 2 | 'nixos-conf-editor', 3 | 'rust', 4 | version: '0.1.2', 5 | meson_version: '>= 0.59', 6 | license: 'GPL-3.0', 7 | ) 8 | 9 | i18n = import('i18n') 10 | gnome = import('gnome') 11 | 12 | base_id = 'dev.vlinkz.NixosConfEditor' 13 | 14 | dependency('openssl', version: '>= 1.0') 15 | dependency('glib-2.0', version: '>= 2.66') 16 | dependency('gio-2.0', version: '>= 2.66') 17 | dependency('gtk4', version: '>= 4.6.0') 18 | dependency('libadwaita-1', version: '>=1.2.0') 19 | dependency('polkit-gobject-1', version: '>= 0.103') 20 | 21 | glib_compile_resources = find_program('glib-compile-resources', required: true) 22 | glib_compile_schemas = find_program('glib-compile-schemas', required: true) 23 | desktop_file_validate = find_program('desktop-file-validate', required: false) 24 | appstream_util = find_program('appstream-util', required: false) 25 | cargo = find_program('cargo', required: true) 26 | 27 | version = meson.project_version() 28 | 29 | prefix = get_option('prefix') 30 | bindir = prefix / get_option('bindir') 31 | libexecdir = prefix / get_option('libexecdir') 32 | localedir = prefix / get_option('localedir') 33 | 34 | datadir = prefix / get_option('datadir') 35 | pkgdatadir = datadir / meson.project_name() 36 | iconsdir = datadir / 'icons' 37 | podir = meson.project_source_root() / 'po' 38 | gettext_package = meson.project_name() 39 | 40 | if get_option('profile') == 'development' 41 | profile = 'Devel' 42 | vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip() 43 | if vcs_tag == '' 44 | version_suffix = '-devel' 45 | else 46 | version_suffix = '-@0@'.format(vcs_tag) 47 | endif 48 | application_id = '@0@.@1@'.format(base_id, profile) 49 | else 50 | profile = '' 51 | version_suffix = '' 52 | application_id = base_id 53 | endif 54 | 55 | meson.add_dist_script( 56 | 'build-aux/dist-vendor.sh', 57 | meson.project_build_root() / 'meson-dist' / meson.project_name() + '-' + version, 58 | meson.project_source_root() 59 | ) 60 | 61 | subdir('data') 62 | subdir('po') 63 | subdir('src') 64 | subdir('nce-helper/src') 65 | 66 | gnome.post_install( 67 | gtk_update_icon_cache: true, 68 | glib_compile_schemas: true, 69 | update_desktop_database: true, 70 | ) 71 | -------------------------------------------------------------------------------- /meson_options.txt: -------------------------------------------------------------------------------- 1 | option( 2 | 'profile', 3 | type: 'combo', 4 | choices: [ 5 | 'default', 6 | 'development' 7 | ], 8 | value: 'default', 9 | ) 10 | -------------------------------------------------------------------------------- /nce-helper/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /nce-helper/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "atty" 7 | version = "0.2.14" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 10 | dependencies = [ 11 | "hermit-abi", 12 | "libc", 13 | "winapi", 14 | ] 15 | 16 | [[package]] 17 | name = "autocfg" 18 | version = "1.1.0" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 21 | 22 | [[package]] 23 | name = "bitflags" 24 | version = "1.3.2" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 27 | 28 | [[package]] 29 | name = "cfg-if" 30 | version = "1.0.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 33 | 34 | [[package]] 35 | name = "clap" 36 | version = "3.1.18" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "d2dbdf4bdacb33466e854ce889eee8dfd5729abf7ccd7664d0a2d60cd384440b" 39 | dependencies = [ 40 | "atty", 41 | "bitflags", 42 | "clap_derive", 43 | "clap_lex", 44 | "indexmap", 45 | "lazy_static", 46 | "strsim", 47 | "termcolor", 48 | "textwrap", 49 | ] 50 | 51 | [[package]] 52 | name = "clap_derive" 53 | version = "3.1.18" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "25320346e922cffe59c0bbc5410c8d8784509efb321488971081313cb1e1a33c" 56 | dependencies = [ 57 | "heck", 58 | "proc-macro-error", 59 | "proc-macro2", 60 | "quote", 61 | "syn", 62 | ] 63 | 64 | [[package]] 65 | name = "clap_lex" 66 | version = "0.2.0" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" 69 | dependencies = [ 70 | "os_str_bytes", 71 | ] 72 | 73 | [[package]] 74 | name = "hashbrown" 75 | version = "0.11.2" 76 | source = "registry+https://github.com/rust-lang/crates.io-index" 77 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 78 | 79 | [[package]] 80 | name = "heck" 81 | version = "0.4.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" 84 | 85 | [[package]] 86 | name = "hermit-abi" 87 | version = "0.1.19" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 90 | dependencies = [ 91 | "libc", 92 | ] 93 | 94 | [[package]] 95 | name = "indexmap" 96 | version = "1.8.2" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" 99 | dependencies = [ 100 | "autocfg", 101 | "hashbrown", 102 | ] 103 | 104 | [[package]] 105 | name = "lazy_static" 106 | version = "1.4.0" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 109 | 110 | [[package]] 111 | name = "libc" 112 | version = "0.2.126" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" 115 | 116 | [[package]] 117 | name = "log" 118 | version = "0.4.17" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 121 | dependencies = [ 122 | "cfg-if", 123 | ] 124 | 125 | [[package]] 126 | name = "nixos-conf-editor-helper" 127 | version = "0.1.0" 128 | dependencies = [ 129 | "clap", 130 | "users", 131 | ] 132 | 133 | [[package]] 134 | name = "os_str_bytes" 135 | version = "6.1.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa" 138 | 139 | [[package]] 140 | name = "proc-macro-error" 141 | version = "1.0.4" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 144 | dependencies = [ 145 | "proc-macro-error-attr", 146 | "proc-macro2", 147 | "quote", 148 | "syn", 149 | "version_check", 150 | ] 151 | 152 | [[package]] 153 | name = "proc-macro-error-attr" 154 | version = "1.0.4" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 157 | dependencies = [ 158 | "proc-macro2", 159 | "quote", 160 | "version_check", 161 | ] 162 | 163 | [[package]] 164 | name = "proc-macro2" 165 | version = "1.0.39" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" 168 | dependencies = [ 169 | "unicode-ident", 170 | ] 171 | 172 | [[package]] 173 | name = "quote" 174 | version = "1.0.18" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" 177 | dependencies = [ 178 | "proc-macro2", 179 | ] 180 | 181 | [[package]] 182 | name = "strsim" 183 | version = "0.10.0" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 186 | 187 | [[package]] 188 | name = "syn" 189 | version = "1.0.95" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "fbaf6116ab8924f39d52792136fb74fd60a80194cf1b1c6ffa6453eef1c3f942" 192 | dependencies = [ 193 | "proc-macro2", 194 | "quote", 195 | "unicode-ident", 196 | ] 197 | 198 | [[package]] 199 | name = "termcolor" 200 | version = "1.1.3" 201 | source = "registry+https://github.com/rust-lang/crates.io-index" 202 | checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" 203 | dependencies = [ 204 | "winapi-util", 205 | ] 206 | 207 | [[package]] 208 | name = "textwrap" 209 | version = "0.15.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 212 | 213 | [[package]] 214 | name = "unicode-ident" 215 | version = "1.0.0" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "d22af068fba1eb5edcb4aea19d382b2a3deb4c8f9d475c589b6ada9e0fd493ee" 218 | 219 | [[package]] 220 | name = "users" 221 | version = "0.11.0" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "24cc0f6d6f267b73e5a2cadf007ba8f9bc39c6a6f9666f8cf25ea809a153b032" 224 | dependencies = [ 225 | "libc", 226 | "log", 227 | ] 228 | 229 | [[package]] 230 | name = "version_check" 231 | version = "0.9.4" 232 | source = "registry+https://github.com/rust-lang/crates.io-index" 233 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 234 | 235 | [[package]] 236 | name = "winapi" 237 | version = "0.3.9" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 240 | dependencies = [ 241 | "winapi-i686-pc-windows-gnu", 242 | "winapi-x86_64-pc-windows-gnu", 243 | ] 244 | 245 | [[package]] 246 | name = "winapi-i686-pc-windows-gnu" 247 | version = "0.4.0" 248 | source = "registry+https://github.com/rust-lang/crates.io-index" 249 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 250 | 251 | [[package]] 252 | name = "winapi-util" 253 | version = "0.1.5" 254 | source = "registry+https://github.com/rust-lang/crates.io-index" 255 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 256 | dependencies = [ 257 | "winapi", 258 | ] 259 | 260 | [[package]] 261 | name = "winapi-x86_64-pc-windows-gnu" 262 | version = "0.4.0" 263 | source = "registry+https://github.com/rust-lang/crates.io-index" 264 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 265 | -------------------------------------------------------------------------------- /nce-helper/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "nce-helper" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | clap = { version = "4.1", features = ["derive"] } 8 | users = "0.11" 9 | 10 | [[bin]] 11 | name = "nce-helper" 12 | path = "src/main.rs" 13 | -------------------------------------------------------------------------------- /nce-helper/src/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{self, FromArgMatches, Subcommand}; 2 | use std::{ 3 | error::Error, 4 | fs::File, 5 | io::{self, Read, Write}, 6 | process::Command, 7 | }; 8 | 9 | #[derive(Subcommand, Debug)] 10 | enum SubCommands { 11 | Config { 12 | /// Write stdin to file in path output 13 | #[arg(short, long)] 14 | output: String, 15 | }, 16 | Rebuild { 17 | /// Run `nixos-rebuild` with the given arguments 18 | arguments: Vec, 19 | }, 20 | WriteRebuild { 21 | /// Content to write to file 22 | #[arg(short, long)] 23 | content: String, 24 | /// Write config to file in path output 25 | #[arg(short, long)] 26 | path: String, 27 | /// Run `nixos-rebuild` with the given arguments 28 | arguments: Vec, 29 | }, 30 | } 31 | 32 | fn main() { 33 | let cli = SubCommands::augment_subcommands(clap::Command::new( 34 | "Helper binary for NixOS Configuration Editor", 35 | )); 36 | let matches = cli.get_matches(); 37 | let derived_subcommands = SubCommands::from_arg_matches(&matches) 38 | .map_err(|err| err.exit()) 39 | .unwrap(); 40 | 41 | if users::get_effective_uid() != 0 { 42 | eprintln!("nixos-conf-editor-helper must be run as root"); 43 | std::process::exit(1); 44 | } 45 | 46 | match derived_subcommands { 47 | SubCommands::Config { output } => { 48 | match write_file(&output) { 49 | Ok(_) => (), 50 | Err(err) => { 51 | eprintln!("{}", err); 52 | std::process::exit(1); 53 | } 54 | }; 55 | } 56 | SubCommands::Rebuild { arguments } => match rebuild(arguments) { 57 | Ok(_) => (), 58 | Err(err) => { 59 | eprintln!("{}", err); 60 | std::process::exit(1); 61 | } 62 | }, 63 | SubCommands::WriteRebuild { 64 | content, 65 | path, 66 | arguments, 67 | } => { 68 | match write_content(&content, &path) { 69 | Ok(_) => (), 70 | Err(err) => { 71 | eprintln!("{}", err); 72 | std::process::exit(1); 73 | } 74 | }; 75 | match rebuild(arguments) { 76 | Ok(_) => (), 77 | Err(err) => { 78 | eprintln!("{}", err); 79 | std::process::exit(1); 80 | } 81 | }; 82 | } 83 | } 84 | } 85 | 86 | fn write_file(path: &str) -> Result<(), Box> { 87 | let stdin = io::stdin(); 88 | let mut buf = String::new(); 89 | stdin.lock().read_to_string(&mut buf)?; 90 | let mut file = File::create(path)?; 91 | write!(file, "{}", &buf)?; 92 | Ok(()) 93 | } 94 | 95 | fn write_content(content: &str, path: &str) -> Result<(), Box> { 96 | let mut file = File::create(path)?; 97 | write!(file, "{}", content)?; 98 | Ok(()) 99 | } 100 | 101 | fn rebuild(args: Vec) -> Result<(), Box> { 102 | let mut cmd = Command::new("nixos-rebuild").args(args).spawn()?; 103 | let x = cmd.wait()?; 104 | if x.success() { 105 | Ok(()) 106 | } else { 107 | eprintln!("nixos-rebuild failed with exit code {}", x.code().unwrap()); 108 | std::process::exit(1); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /nce-helper/src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'nce-helper' / 'Cargo.toml' ] 3 | cargo_options += [ '--target-dir', meson.project_build_root() / 'nce-helper' / 'src' ] 4 | 5 | if get_option('profile') == 'default' 6 | cargo_options += [ '--release' ] 7 | rust_target = 'release' 8 | message('Building in release mode') 9 | else 10 | rust_target = 'debug' 11 | message('Building in debug mode') 12 | endif 13 | 14 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'nce-helper' / 'cargo-home' ] 15 | 16 | cargo_build = custom_target( 17 | 'cargo-build', 18 | build_by_default: true, 19 | build_always_stale: true, 20 | output: 'nce-helper', 21 | console: true, 22 | install: true, 23 | install_dir: get_option('libexecdir'), 24 | command: [ 25 | 'env', 26 | cargo_env, 27 | cargo, 'build', 28 | cargo_options, 29 | '&&', 30 | 'cp', 'nce-helper' / 'src' / rust_target / 'nce-helper', '@OUTPUT@', 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /packages/nixos-conf-editor/default.nix: -------------------------------------------------------------------------------- 1 | { stdenv 2 | , lib 3 | , appstream-glib 4 | , cargo 5 | , desktop-file-utils 6 | , gdk-pixbuf 7 | , gettext 8 | , git 9 | , glib 10 | , gnome 11 | , gtk4 12 | , gtksourceview5 13 | , libadwaita 14 | , meson 15 | , ninja 16 | , openssl 17 | , pandoc 18 | , pkg-config 19 | , polkit 20 | , rustc 21 | , rustPlatform 22 | , vte-gtk4 23 | , wrapGAppsHook4 24 | }: 25 | stdenv.mkDerivation rec { 26 | pname = "nixos-conf-editor"; 27 | version = "0.1.2"; 28 | 29 | src = [ ../.. ]; 30 | 31 | cargoDeps = rustPlatform.importCargoLock { 32 | lockFile = ../../Cargo.lock; 33 | }; 34 | 35 | nativeBuildInputs = [ 36 | appstream-glib 37 | desktop-file-utils 38 | gettext 39 | git 40 | meson 41 | ninja 42 | pkg-config 43 | polkit 44 | wrapGAppsHook4 45 | ] ++ (with rustPlatform; [ 46 | cargo 47 | cargoSetupHook 48 | rustc 49 | ]); 50 | 51 | buildInputs = [ 52 | gdk-pixbuf 53 | glib 54 | gnome.adwaita-icon-theme 55 | gtk4 56 | gtksourceview5 57 | libadwaita 58 | openssl 59 | vte-gtk4 60 | ]; 61 | 62 | postInstall = '' 63 | wrapProgram $out/bin/nixos-conf-editor --prefix PATH : '${lib.makeBinPath [ pandoc ]}' 64 | ''; 65 | } 66 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snowfallorg/nixos-conf-editor/27b5e92f580f794c690093503869aab242f075ab/po/LINGUAS -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | data/dev.vlinkz.NixosConfEditor.policy.in.in 2 | data/dev.vlinkz.NixosConfEditor.desktop.in.in 3 | data/dev.vlinkz.NixosConfEditor.metainfo.xml.in.in 4 | data/dev.vlinkz.NixosConfEditor.metainfo.gschema.xml.in 5 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | i18n.gettext(gettext_package, preset: 'glib') -------------------------------------------------------------------------------- /shells/default/default.nix: -------------------------------------------------------------------------------- 1 | { mkShell 2 | , cairo 3 | , cargo 4 | , clippy 5 | , desktop-file-utils 6 | , gdk-pixbuf 7 | , gettext 8 | , gobject-introspection 9 | , graphene 10 | , gtk4 11 | , gtksourceview5 12 | , libadwaita 13 | , meson 14 | , ninja 15 | , openssl 16 | , pandoc 17 | , pango 18 | , pkg-config 19 | , polkit 20 | , rust 21 | , rust-analyzer 22 | , rustc 23 | , rustfmt 24 | , vte-gtk4 25 | , wrapGAppsHook4 26 | }: 27 | 28 | mkShell { 29 | buildInputs = [ 30 | cairo 31 | cargo 32 | clippy 33 | desktop-file-utils 34 | gdk-pixbuf 35 | gettext 36 | gobject-introspection 37 | graphene 38 | gtk4 39 | gtksourceview5 40 | libadwaita 41 | meson 42 | ninja 43 | openssl 44 | pandoc 45 | pango 46 | pkg-config 47 | polkit 48 | rust-analyzer 49 | rustc 50 | rustfmt 51 | vte-gtk4 52 | wrapGAppsHook4 53 | ]; 54 | RUST_SRC_PATH = "${rust.packages.stable.rustPlatform.rustLibSrc}"; 55 | } 56 | -------------------------------------------------------------------------------- /src/config.rs.in: -------------------------------------------------------------------------------- 1 | pub const APP_ID: &str = @APP_ID@; 2 | pub const GETTEXT_PACKAGE: &str = @GETTEXT_PACKAGE@; 3 | pub const LOCALEDIR: &str = @LOCALEDIR@; 4 | pub const PKGDATADIR: &str = @PKGDATADIR@; 5 | pub const LIBEXECDIR: &str = @LIBEXECDIR@; 6 | pub const PROFILE: &str = @PROFILE@; 7 | pub const RESOURCES_FILE: &str = concat!(@PKGDATADIR@, "/resources.gresource"); 8 | pub const VERSION: &str = @VERSION@; 9 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod parse; 3 | pub mod ui; 4 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use gettextrs::{gettext, LocaleCategory}; 2 | use gtk::{gio, glib, prelude::ApplicationExt}; 3 | use nixos_conf_editor::{ 4 | config::{APP_ID, GETTEXT_PACKAGE, LOCALEDIR}, 5 | ui::window::AppModel, 6 | }; 7 | use relm4::{actions::AccelsPlus, RelmApp}; 8 | 9 | relm4::new_action_group!(WindowActionGroup, "window"); 10 | relm4::new_stateless_action!(SearchAction, WindowActionGroup, "search"); 11 | 12 | fn main() { 13 | gtk::init().unwrap(); 14 | pretty_env_logger::init(); 15 | setup_gettext(); 16 | glib::set_application_name(&gettext("Configuration Editor")); 17 | let app = adw::Application::new(Some(APP_ID), gio::ApplicationFlags::empty()); 18 | app.set_resource_base_path(Some("/dev/vlinkz/NixosConfEditor")); 19 | app.set_accelerators_for_action::(&["f"]); 20 | let app = RelmApp::with_app(app); 21 | app.run::(()); 22 | } 23 | 24 | fn setup_gettext() { 25 | // Prepare i18n 26 | gettextrs::setlocale(LocaleCategory::LcAll, ""); 27 | gettextrs::bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).expect("Unable to bind the text domain"); 28 | gettextrs::bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8") 29 | .expect("Unable to bind the text domain codeset to UTF-8"); 30 | gettextrs::textdomain(GETTEXT_PACKAGE).expect("Unable to switch to the text domain"); 31 | } 32 | -------------------------------------------------------------------------------- /src/meson.build: -------------------------------------------------------------------------------- 1 | global_conf = configuration_data() 2 | global_conf.set_quoted('APP_ID', application_id) 3 | global_conf.set_quoted('PKGDATADIR', pkgdatadir) 4 | global_conf.set_quoted('PROFILE', profile) 5 | global_conf.set_quoted('VERSION', version + version_suffix) 6 | global_conf.set_quoted('GETTEXT_PACKAGE', gettext_package) 7 | global_conf.set_quoted('LOCALEDIR', localedir) 8 | global_conf.set_quoted('LIBEXECDIR', libexecdir) 9 | config = configure_file( 10 | input: 'config.rs.in', 11 | output: 'config.rs', 12 | configuration: global_conf 13 | ) 14 | # Copy the config.rs output to the source directory. 15 | run_command( 16 | 'cp', 17 | meson.project_build_root() / 'src' / 'config.rs', 18 | meson.project_source_root() / 'src' / 'config.rs', 19 | check: true 20 | ) 21 | 22 | cargo_options = [ '--manifest-path', meson.project_source_root() / 'Cargo.toml' ] 23 | cargo_options += [ '--target-dir', meson.project_build_root() / 'src' ] 24 | 25 | if get_option('profile') == 'default' 26 | cargo_options += [ '--release' ] 27 | rust_target = 'release' 28 | message('Building in release mode') 29 | else 30 | rust_target = 'debug' 31 | message('Building in debug mode') 32 | endif 33 | 34 | cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home' ] 35 | 36 | cargo_build = custom_target( 37 | 'cargo-build', 38 | build_by_default: true, 39 | build_always_stale: true, 40 | output: meson.project_name(), 41 | console: true, 42 | install: true, 43 | install_dir: bindir, 44 | command: [ 45 | 'env', 46 | cargo_env, 47 | cargo, 'build', 48 | cargo_options, 49 | '&&', 50 | 'cp', 'src' / rust_target / meson.project_name(), '@OUTPUT@', 51 | ] 52 | ) 53 | -------------------------------------------------------------------------------- /src/parse/config.rs: -------------------------------------------------------------------------------- 1 | use log::{debug, warn}; 2 | use nix_editor; 3 | use std::{collections::HashMap, error::Error, fs, io, path::Path}; 4 | 5 | pub fn parseconfig(path: &str) -> Result, Box> { 6 | let f = fs::read_to_string(Path::new(path))?; 7 | match nix_editor::parse::get_collection(f) { 8 | Ok(x) => Ok(x), 9 | Err(_) => Err(Box::new(io::Error::new( 10 | io::ErrorKind::InvalidData, 11 | "Failed to parse config", 12 | ))), 13 | } 14 | } 15 | 16 | pub fn opconfigured( 17 | conf: &HashMap, 18 | pos: &[String], 19 | attr: String, 20 | ) -> bool { 21 | let mut p = pos.to_vec(); 22 | p.push(attr); 23 | conf.keys().any(|k| { 24 | let s = k.split('.').collect::>(); 25 | if s.len() < p.len() { 26 | false 27 | } else { 28 | s[0..p.len()].eq(&p) 29 | } 30 | }) 31 | } 32 | 33 | pub fn opconfigured2(path: &str, pos: &[String], refpos: &[String], attr: String) -> bool { 34 | let mut p = pos.to_vec(); 35 | p.push(attr.clone()); 36 | let mut r = refpos.to_vec(); 37 | r.push(attr); 38 | readval(path, &p.join("."), &r.join(".")).is_ok() 39 | } 40 | 41 | pub fn getconfvals(conf: &HashMap, pos: &[String]) -> Vec { 42 | let mut out = vec![]; 43 | for attr in conf.keys() { 44 | let k = attr.split('.').collect::>(); 45 | if k.len() > pos.len() && k[0..pos.len()].eq(pos) { 46 | let x = k[pos.len()].to_string(); 47 | if !out.contains(&x) { 48 | out.push(x); 49 | } 50 | } 51 | } 52 | out 53 | } 54 | 55 | pub fn getarrvals(path: &str, pos: &[String]) -> Vec { 56 | let f = fs::read_to_string(Path::new(path)).unwrap(); 57 | let out = nix_editor::read::getarrvals(&f, &pos.join(".")); 58 | match out { 59 | Ok(x) => x, 60 | Err(_) => vec![], 61 | } 62 | } 63 | 64 | pub fn editconfigpath( 65 | path: &str, 66 | editedopts: HashMap, 67 | ) -> Result> { 68 | let f = fs::read_to_string(Path::new(path))?; 69 | editconfig(f, editedopts) 70 | } 71 | 72 | pub fn editconfig( 73 | mut f: String, 74 | //path: &str, 75 | editedopts: HashMap, 76 | ) -> Result> { 77 | debug!("editedopts: {:#?}", editedopts); 78 | let mut k = editedopts.keys().collect::>(); 79 | k.sort(); 80 | 81 | let mut starops: HashMap> = HashMap::new(); 82 | for (op, val) in editedopts.into_iter() { 83 | if op.split('.').any(|x| x.parse::().is_ok()) { 84 | let option = op.split('.').collect::>(); 85 | let index = option 86 | .iter() 87 | .position(|x| x.parse::().is_ok()) 88 | .unwrap(); 89 | let o = &option[..index]; 90 | let v = &option[index + 1..]; 91 | let i = option[index].parse::().unwrap(); 92 | 93 | let mut p = if let Some(y) = starops.get(&o.join(".")) { 94 | y.to_owned() 95 | } else { 96 | HashMap::new() 97 | }; 98 | // fill up on first time 99 | if p.is_empty() { 100 | let arr = match nix_editor::read::getarrvals(&f, &o.join(".")) { 101 | Ok(x) => x, 102 | Err(_) => vec![], 103 | }; 104 | for (j, a) in arr.iter().enumerate() { 105 | p.insert(j, a.to_string()); 106 | } 107 | } 108 | 109 | let arrval = match p.get(&i) { 110 | Some(x) => x.to_string(), 111 | None => "{}".to_string(), 112 | }; 113 | let mut h = HashMap::new(); 114 | h.insert(v.join("."), val); 115 | p.insert(i, editconfig(arrval, h)?); 116 | starops.insert(o.join("."), p); 117 | } else if val.is_empty() { 118 | f = match nix_editor::write::deref(&f, &op) { 119 | Ok(x) => x, 120 | Err(_) => { 121 | return Err(Box::new(io::Error::new( 122 | io::ErrorKind::InvalidData, 123 | format!("Failed to deref {}", op), 124 | ))) 125 | } 126 | }; 127 | } else { 128 | f = match nix_editor::write::write(&f, &op, &val) { 129 | Ok(x) => x, 130 | Err(_) => { 131 | return Err(Box::new(io::Error::new( 132 | io::ErrorKind::InvalidData, 133 | format!("Failed to set value {} to {}", op, val), 134 | ))) 135 | } 136 | }; 137 | } 138 | } 139 | for (k, v) in starops { 140 | let mut arr = v.into_iter().collect::>(); 141 | arr.sort_by(|(x, _), (y, _)| x.cmp(y)); 142 | // Just use nixpkgs-fmt instead 143 | let valarr = format!( 144 | "[\n{}\n ]", 145 | arr.iter() 146 | .filter(|(_, x)| x.trim().replace(['\n', ' '], "") != "{}") 147 | .map(|(_, y)| format!( 148 | " {}", 149 | y.replace(";\n ", ";\n ") 150 | .replace("; }", ";\n }") 151 | .replace("; ", ";\n ") 152 | .replace(";\n ", ";\n ") 153 | .replace("{ ", "{\n ") 154 | .replace(";}", ";\n }") 155 | )) 156 | .collect::>() 157 | .join("\n") 158 | ); 159 | f = match nix_editor::write::write(&f, &k, &valarr) { 160 | Ok(x) => x, 161 | Err(_) => { 162 | return Err(Box::new(io::Error::new( 163 | io::ErrorKind::InvalidData, 164 | format!("Failed to set value {} to {}", k, valarr), 165 | ))) 166 | } 167 | }; 168 | } 169 | Ok(f) 170 | } 171 | 172 | pub fn readval(path: &str, query: &str, refq: &str) -> Result> { 173 | warn!("READVAL: {} {} {}", path, query, refq); 174 | let f = fs::read_to_string(Path::new(path))?; 175 | let out = if !refq.contains(&String::from("*")) { 176 | nix_editor::read::readvalue(&f, query) 177 | } else { 178 | let p = refq.split('.').collect::>(); 179 | let mut r: Vec> = vec![vec![]]; 180 | let mut indexvec: Vec = vec![]; 181 | let mut j = 0; 182 | for (i, attr) in p.iter().enumerate() { 183 | if *attr == "*" { 184 | r.push(vec![]); 185 | if let Ok(x) = query.split('.').collect::>()[i].parse::() { 186 | indexvec.push(x); 187 | } 188 | j += 1; 189 | } else { 190 | r[j].push(attr.to_string()); 191 | } 192 | } 193 | let mut f = fs::read_to_string(Path::new(path)).unwrap(); 194 | let mut i = 0; 195 | for y in r { 196 | if i < indexvec.len() { 197 | f = match nix_editor::read::getarrvals(&f, &y.join(".")) { 198 | Ok(x) => { 199 | warn!("x: {:?}", x); 200 | let o = match x.get(indexvec[i]) { 201 | Some(x) => x.to_string(), 202 | None => { 203 | return Err(Box::new(io::Error::new( 204 | io::ErrorKind::InvalidData, 205 | format!("Index out of bounds {}", refq), 206 | ))) 207 | } 208 | }; 209 | i += 1; 210 | o 211 | } 212 | Err(_) => { 213 | return Err(Box::new(io::Error::new( 214 | io::ErrorKind::InvalidData, 215 | "Failed to read value from configuration", 216 | ))) 217 | } 218 | }; 219 | } else { 220 | f = match nix_editor::read::readvalue(&f, &y.join(".")) { 221 | Ok(x) => x, 222 | Err(_) => { 223 | return Err(Box::new(io::Error::new( 224 | io::ErrorKind::InvalidData, 225 | "Failed to read value from configuration", 226 | ))) 227 | } 228 | }; 229 | } 230 | } 231 | Ok(f) 232 | }; 233 | match out { 234 | Ok(x) => Ok(x), 235 | Err(_) => Err(Box::new(io::Error::new( 236 | io::ErrorKind::InvalidData, 237 | format!("Failed to read value {}", query), 238 | ))), 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/parse/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod options; 3 | pub mod preferences; 4 | -------------------------------------------------------------------------------- /src/parse/options.rs: -------------------------------------------------------------------------------- 1 | use ijson::{IString, IValue}; 2 | use serde::{Deserialize, Serialize}; 3 | use serde_json; 4 | use std::{self, cmp::Ordering, collections::HashMap, error::Error, fs}; 5 | 6 | #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Default, Clone)] 7 | pub struct OptionData { 8 | pub default: Option, 9 | pub description: IValue, 10 | #[serde(alias = "readOnly")] 11 | pub read_only: bool, 12 | #[serde(alias = "type")] 13 | pub op_type: IString, 14 | pub declarations: Vec, 15 | pub example: Option, 16 | } 17 | 18 | #[derive(Default, Debug, PartialEq)] 19 | pub struct AttrTree { 20 | pub attributes: HashMap, 21 | pub options: Vec, 22 | } 23 | 24 | pub fn read(file: &str) -> Result<(HashMap, AttrTree), Box> { 25 | let f = fs::read_to_string(file)?; 26 | let data: HashMap = serde_json::from_str(&f)?; 27 | let ops = data.keys().map(|x| x.as_str()).collect::>(); 28 | let tree = buildtree(ops)?; 29 | Ok((data, tree)) 30 | } 31 | 32 | pub fn attrloc(tree: &AttrTree, pos: Vec) -> Option<&AttrTree> { 33 | match pos.len().cmp(&1) { 34 | Ordering::Greater => match tree.attributes.get(&pos[0]) { 35 | Some(x) => attrloc(x, pos[1..].to_vec()), 36 | None => None, 37 | }, 38 | Ordering::Equal => tree.attributes.get(&pos[0]), 39 | Ordering::Less => Some(tree), 40 | } 41 | } 42 | 43 | fn buildtree(ops: Vec<&str>) -> Result> { 44 | let split = ops 45 | .into_iter() 46 | .map(|x| x.split('.').collect::>()) 47 | .collect::>(); 48 | let mut tree = AttrTree { 49 | attributes: HashMap::new(), 50 | options: vec![], 51 | }; 52 | for attr in split { 53 | match attr.len().cmp(&1) { 54 | Ordering::Greater => { 55 | if tree.attributes.get(attr[0]).is_none() { 56 | tree.attributes.insert( 57 | attr[0].to_string(), 58 | AttrTree { 59 | attributes: HashMap::new(), 60 | options: vec![], 61 | }, 62 | ); 63 | } 64 | buildtree_child( 65 | tree.attributes.get_mut(attr[0]).unwrap(), 66 | attr[1..].to_vec(), 67 | )?; 68 | } 69 | Ordering::Equal => tree.options.push(attr[0].to_string()), 70 | Ordering::Less => {} 71 | } 72 | } 73 | Ok(tree) 74 | } 75 | 76 | fn buildtree_child(tree: &mut AttrTree, attr: Vec<&str>) -> Result<(), Box> { 77 | match attr.len().cmp(&1) { 78 | Ordering::Greater => { 79 | if tree.attributes.get(attr[0]).is_none() { 80 | tree.attributes.insert( 81 | attr[0].to_string(), 82 | AttrTree { 83 | attributes: HashMap::new(), 84 | options: vec![], 85 | }, 86 | ); 87 | } 88 | buildtree_child( 89 | tree.attributes.get_mut(attr[0]).unwrap(), 90 | attr[1..].to_vec(), 91 | ) 92 | } 93 | Ordering::Equal => { 94 | tree.options.push(attr[0].to_string()); 95 | Ok(()) 96 | } 97 | Ordering::Less => Ok(()), 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/parse/preferences.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use nix_data::config::configfile::NixDataConfig; 3 | 4 | pub fn getconfig() -> Option { 5 | if let Ok(c) = nix_data::config::configfile::getconfig() { 6 | Some(c) 7 | } else { 8 | None 9 | } 10 | } 11 | 12 | pub fn editconfig(config: NixDataConfig) -> Result<()> { 13 | nix_data::config::configfile::setuserconfig(config)?; 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/about.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use crate::config; 3 | use adw::prelude::*; 4 | use relm4::*; 5 | 6 | #[derive(Debug)] 7 | pub struct AboutPageModel; 8 | 9 | #[relm4::component(pub)] 10 | impl SimpleComponent for AboutPageModel { 11 | type Init = gtk::Window; 12 | type Input = (); 13 | type Output = AppMsg; 14 | type Widgets = AboutPageWidgets; 15 | 16 | view! { 17 | adw::AboutWindow { 18 | set_transient_for: Some(&parent_window), 19 | set_modal: true, 20 | set_application_name: "NixOS Configuration Editor", 21 | set_application_icon: config::APP_ID, 22 | set_developer_name: "Victor Fuentes", 23 | set_version: config::VERSION, 24 | set_issue_url: "https://github.com/vlinkz/nixos-conf-editor/issues", 25 | set_license_type: gtk::License::Gpl30, 26 | set_website: "https://github.com/vlinkz/nixos-conf-editor", 27 | set_developers: &["Victor Fuentes https://github.com/vlinkz"], 28 | } 29 | } 30 | 31 | fn init( 32 | parent_window: Self::Init, 33 | root: &Self::Root, 34 | _sender: ComponentSender, 35 | ) -> ComponentParts { 36 | let model = AboutPageModel; 37 | 38 | let widgets = view_output!(); 39 | 40 | ComponentParts { model, widgets } 41 | } 42 | 43 | fn update(&mut self, _msg: Self::Input, _sender: ComponentSender) {} 44 | } 45 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | mod about; 2 | mod nameentry; 3 | mod optionpage; 4 | mod preferencespage; 5 | mod quitdialog; 6 | mod rebuild; 7 | mod savechecking; 8 | mod searchentry; 9 | mod searchfactory; 10 | mod searchpage; 11 | mod treefactory; 12 | mod welcome; 13 | pub mod window; 14 | mod windowloading; 15 | -------------------------------------------------------------------------------- /src/ui/nameentry.rs: -------------------------------------------------------------------------------- 1 | use super::window::*; 2 | use adw::prelude::*; 3 | use relm4::*; 4 | 5 | pub struct NameEntryModel { 6 | hidden: bool, 7 | msg: String, 8 | existing: Vec, 9 | text: String, 10 | } 11 | 12 | #[derive(Debug)] 13 | pub enum NameEntryMsg { 14 | Show(String, Vec), 15 | Cancel, 16 | Save, 17 | SetText(String), 18 | } 19 | 20 | #[relm4::component(pub)] 21 | impl SimpleComponent for NameEntryModel { 22 | type Init = gtk::Window; 23 | type Input = NameEntryMsg; 24 | type Output = AppMsg; 25 | type Widgets = NameEntryWidgets; 26 | 27 | view! { 28 | dialog = gtk::MessageDialog { 29 | set_transient_for: Some(&parent_window), 30 | set_modal: true, 31 | #[watch] 32 | set_visible: !model.hidden, 33 | set_text: Some("Enter a new value"), 34 | set_secondary_text: None, 35 | add_button: ("Save", gtk::ResponseType::Accept), 36 | add_button: ("Cancel", gtk::ResponseType::Cancel), 37 | connect_response[sender] => move |_, resp| { 38 | sender.input(match resp { 39 | gtk::ResponseType::Accept => NameEntryMsg::Save, 40 | gtk::ResponseType::Cancel => NameEntryMsg::Cancel, 41 | _ => unreachable!(), 42 | }); 43 | } 44 | } 45 | } 46 | 47 | additional_fields! { 48 | textentry: gtk::Entry, 49 | msgbuf: gtk::EntryBuffer, 50 | } 51 | 52 | fn init( 53 | parent_window: Self::Init, 54 | root: &Self::Root, 55 | sender: ComponentSender, 56 | ) -> ComponentParts { 57 | let model = NameEntryModel { 58 | hidden: true, 59 | msg: String::default(), 60 | text: String::default(), 61 | existing: vec![], 62 | }; 63 | 64 | view! { 65 | textentry = gtk::Entry { 66 | set_margin_start: 20, 67 | set_margin_end: 20, 68 | set_hexpand: true, 69 | set_buffer: msgbuf = >k::EntryBuffer { 70 | connect_text_notify[sender] => move |x| { 71 | sender.input(NameEntryMsg::SetText(x.text())); 72 | }, 73 | } 74 | } 75 | } 76 | 77 | let widgets = view_output!(); 78 | 79 | widgets.dialog.content_area().append(&widgets.textentry); 80 | let accept_widget = widgets 81 | .dialog 82 | .widget_for_response(gtk::ResponseType::Accept) 83 | .expect("No button for accept response set"); 84 | accept_widget.set_sensitive(false); 85 | 86 | ComponentParts { model, widgets } 87 | } 88 | 89 | fn pre_view() { 90 | let accept_widget = dialog 91 | .widget_for_response(gtk::ResponseType::Accept) 92 | .expect("No button for accept response set"); 93 | if model.text.is_empty() || model.existing.contains(&model.text) { 94 | accept_widget.set_css_classes(&[]); 95 | accept_widget.set_sensitive(false); 96 | } else { 97 | accept_widget.set_css_classes(&["suggested-action"]); 98 | accept_widget.set_sensitive(true); 99 | } 100 | if model.hidden { 101 | msgbuf.set_text(""); 102 | } 103 | } 104 | 105 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 106 | match msg { 107 | NameEntryMsg::Show(msg, existing) => { 108 | self.hidden = false; 109 | self.msg = msg; 110 | self.existing = existing; 111 | self.text = String::default(); 112 | } 113 | NameEntryMsg::Cancel => self.hidden = true, 114 | NameEntryMsg::Save => { 115 | self.hidden = true; 116 | let _ = sender.output(AppMsg::AddNameAttr(None, self.text.clone())); 117 | } 118 | NameEntryMsg::SetText(s) => { 119 | self.text = s; 120 | } 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/ui/optionpage.rs: -------------------------------------------------------------------------------- 1 | use super::savechecking::*; 2 | use super::window::*; 3 | use crate::parse::options::OptionData; 4 | use adw::prelude::*; 5 | use html2pango; 6 | use log::*; 7 | use pandoc::{self, MarkdownExtension}; 8 | use relm4::*; 9 | use sourceview5::prelude::*; 10 | use std::convert::identity; 11 | 12 | #[derive(Debug)] 13 | pub enum OptPageMsg { 14 | UpdateOption( 15 | Box, 16 | Vec, 17 | Vec, 18 | String, 19 | Vec, 20 | ), 21 | UpdateConf(String), 22 | UpdateConfMod(String), 23 | ResetConf, 24 | ClearConf, 25 | SaveConf, 26 | DoneSaving(bool, String), 27 | SetScheme(String), 28 | } 29 | 30 | #[tracker::track] 31 | pub struct OptPageModel { 32 | pub opt: Vec, 33 | pub refopt: Vec, 34 | pub data: OptionData, 35 | pub conf: String, 36 | pub modifiedconf: String, 37 | alloptions: Vec, 38 | scheme: Option, 39 | saving: bool, 40 | resettracker: u8, 41 | valuetracker: u8, 42 | #[tracker::no_eq] 43 | async_handler: WorkerController, 44 | } 45 | 46 | #[relm4::component(pub)] 47 | impl SimpleComponent for OptPageModel { 48 | type Init = (); 49 | type Input = OptPageMsg; 50 | type Output = AppMsg; 51 | type Widgets = OptPageWidgets; 52 | 53 | view! { 54 | optwindow = gtk::ScrolledWindow { 55 | adw::Clamp { 56 | gtk::Box { 57 | set_orientation: gtk::Orientation::Vertical, 58 | set_margin_all: 15, 59 | set_spacing: 15, 60 | set_vexpand: true, 61 | add_css_class: "labels", 62 | gtk::Label { 63 | set_margin_top: 5, 64 | set_margin_bottom: 5, 65 | set_halign: gtk::Align::Start, 66 | add_css_class: "title-1", 67 | #[watch] 68 | set_label: &model.opt.join(".") 69 | }, 70 | 71 | gtk::Box { 72 | set_orientation: gtk::Orientation::Vertical, 73 | set_spacing: 10, 74 | gtk::Box { 75 | set_orientation: gtk::Orientation::Horizontal, 76 | add_css_class: "header", 77 | add_css_class: "single-line", 78 | gtk::Label { 79 | set_halign: gtk::Align::Start, 80 | add_css_class: "heading", 81 | set_label: "Description", 82 | } 83 | }, 84 | #[name(desc)] 85 | gtk::Label { 86 | set_halign: gtk::Align::Start, 87 | set_wrap: true, 88 | add_css_class: "body", 89 | #[track(model.changed(OptPageModel::data()))] 90 | set_markup: { 91 | let mut pandoc = pandoc::new(); 92 | 93 | let x = if let Some(description) = model.data.description.as_object().and_then(|x| x.get("text")).and_then(|x| x.as_string()).map(|x| x.to_string()) { 94 | description.trim().to_string() 95 | } else { 96 | model.data.description.as_string().map(|x| x.to_string()).unwrap_or_default().trim().to_string() 97 | }; 98 | 99 | pandoc.set_input(pandoc::InputKind::Pipe(x.replace("{option}", ""))); 100 | pandoc.set_output(pandoc::OutputKind::Pipe); 101 | pandoc.set_input_format(pandoc::InputFormat::Markdown, vec![MarkdownExtension::ListsWithoutPrecedingBlankline]); 102 | pandoc.set_output_format(pandoc::OutputFormat::Html, vec![]); 103 | 104 | let out = pandoc.execute().unwrap(); 105 | let y = match out { 106 | pandoc::PandocOutput::ToBuffer(s) => s, 107 | _ => "".to_string(), 108 | }; 109 | let mut pango = html2pango::markup_html(&y.replace('\n', " \n")).unwrap_or(y).replace("• \n", "• ").trim().to_string(); 110 | while pango.ends_with('\n') { 111 | pango.pop(); 112 | } 113 | pango.strip_prefix('\n').unwrap_or(&pango).to_string().as_str() 114 | }, 115 | }, 116 | }, 117 | 118 | 119 | gtk::Box { 120 | set_orientation: gtk::Orientation::Vertical, 121 | set_spacing: 10, 122 | #[name(type_header_box)] 123 | gtk::Box { 124 | set_orientation: gtk::Orientation::Horizontal, 125 | add_css_class: "header", 126 | add_css_class: "single-line", 127 | #[name(type_header)] 128 | gtk::Label { 129 | set_halign: gtk::Align::Start, 130 | add_css_class: "heading", 131 | set_label: "Type", 132 | } 133 | }, 134 | gtk::Label { 135 | set_halign: gtk::Align::Start, 136 | set_wrap: true, 137 | add_css_class: "body", 138 | #[watch] 139 | set_label: &model.data.op_type, 140 | }, 141 | }, 142 | 143 | gtk::Box { 144 | set_orientation: gtk::Orientation::Vertical, 145 | set_spacing: 10, 146 | #[watch] 147 | set_visible: model.data.default.is_some(), 148 | #[name(default_box)] 149 | gtk::Box { 150 | set_orientation: gtk::Orientation::Horizontal, 151 | add_css_class: "header", 152 | add_css_class: "single-line", 153 | #[name(default_header)] 154 | gtk::Label { 155 | set_halign: gtk::Align::Start, 156 | add_css_class: "heading", 157 | set_label: "Default", 158 | } 159 | }, 160 | gtk::Frame { 161 | add_css_class: "code", 162 | sourceview5::View { 163 | set_editable: false, 164 | set_monospace: true, 165 | set_cursor_visible: false, 166 | set_top_margin: 5, 167 | set_bottom_margin: 5, 168 | set_left_margin: 5, 169 | #[wrap(Some)] 170 | set_buffer: defaultbuf = &sourceview5::Buffer { 171 | #[track(model.changed(OptPageModel::scheme()))] 172 | set_style_scheme: model.scheme.as_ref(), 173 | #[watch] 174 | set_text: { 175 | let x = &model.data.default.as_ref().map(|x| ijson::from_value::(x).unwrap_or_default()).unwrap_or(serde_json::Value::Null); 176 | &match x { 177 | serde_json::Value::Object(o) => match o.get("text") { 178 | Some(serde_json::Value::String(s)) => { 179 | if o.get("_type").unwrap_or(&serde_json::Value::Null).as_str().unwrap_or("").eq("literalExpression") { 180 | s.strip_suffix('\n').unwrap_or(s).to_string() 181 | } else { 182 | serde_json::to_string_pretty(x).unwrap_or_default() 183 | } 184 | }, 185 | _ => serde_json::to_string_pretty(x).unwrap_or_default(), 186 | }, 187 | _ => serde_json::to_string_pretty(x).unwrap_or_default(), 188 | } 189 | }, 190 | } 191 | }, 192 | }, 193 | }, 194 | 195 | gtk::Box { 196 | set_orientation: gtk::Orientation::Vertical, 197 | set_spacing: 10, 198 | #[watch] 199 | set_visible: model.data.example.is_some(), 200 | #[name(example_box)] 201 | gtk::Box { 202 | set_orientation: gtk::Orientation::Horizontal, 203 | add_css_class: "header", 204 | add_css_class: "single-line", 205 | #[name(example_header)] 206 | gtk::Label { 207 | set_halign: gtk::Align::Start, 208 | add_css_class: "heading", 209 | add_css_class: "h4", 210 | set_label: "Example", 211 | } 212 | }, 213 | gtk::Frame { 214 | add_css_class: "code", 215 | sourceview5::View { 216 | set_editable: false, 217 | set_monospace: true, 218 | set_cursor_visible: false, 219 | set_top_margin: 5, 220 | set_bottom_margin: 5, 221 | set_left_margin: 5, 222 | #[wrap(Some)] 223 | set_buffer: exbuf = &sourceview5::Buffer { 224 | #[track(model.changed(OptPageModel::scheme()))] 225 | set_style_scheme: model.scheme.as_ref(), 226 | #[watch] 227 | set_text: { 228 | let x = &model.data.example.as_ref().map(|x| ijson::from_value::(x).unwrap_or_default()).unwrap_or(serde_json::Value::Null); 229 | &match x { 230 | serde_json::Value::Object(o) => match o.get("text") { 231 | Some(serde_json::Value::String(s)) => { 232 | if o.get("_type").unwrap_or(&serde_json::Value::Null).as_str().unwrap_or("").eq("literalExpression") { 233 | s.strip_suffix('\n').unwrap_or(s).to_string() 234 | } else { 235 | serde_json::to_string_pretty(x).unwrap_or_default() 236 | } 237 | }, 238 | _ => serde_json::to_string_pretty(x).unwrap_or_default(), 239 | }, 240 | _ => serde_json::to_string_pretty(x).unwrap_or_default(), 241 | } 242 | }, 243 | } 244 | }, 245 | }, 246 | }, 247 | gtk::Separator { 248 | set_opacity: 0.0, 249 | set_margin_top: 5, 250 | }, 251 | gtk::Box { 252 | #[watch] 253 | set_visible: valuestack.is_child_visible(), 254 | set_orientation: gtk::Orientation::Vertical, 255 | set_spacing: 10, 256 | #[name(simplevalue_box)] 257 | gtk::Box { 258 | set_orientation: gtk::Orientation::Horizontal, 259 | add_css_class: "header", 260 | add_css_class: "single-line", 261 | append = >k::Label { 262 | set_halign: gtk::Align::Start, 263 | add_css_class: "heading", 264 | set_label: "Value", 265 | } 266 | }, 267 | #[name(valuestack)] 268 | gtk::Stack { 269 | #[name(number)] 270 | gtk::SpinButton { 271 | set_halign: gtk::Align::Start, 272 | set_adjustment: >k::Adjustment::new(0.0, f64::MIN, f64::MAX, 1.0, 5.0, 0.0), 273 | set_climb_rate: 1.0, 274 | set_digits: 0, 275 | connect_value_changed[sender] => move |x| { 276 | if x.is_sensitive() { 277 | sender.input(OptPageMsg::UpdateConfMod(x.value().to_string())) 278 | } 279 | }, 280 | }, 281 | #[name(stringentry)] 282 | gtk::Entry { 283 | set_halign: gtk::Align::Start, 284 | connect_changed[sender] => move |x| { 285 | if x.is_sensitive() { 286 | sender.input(OptPageMsg::UpdateConfMod(format!("\"{}\"", x.text()))); 287 | } 288 | }, 289 | }, 290 | #[name(truefalse)] 291 | gtk::Box { 292 | add_css_class: "linked", 293 | set_orientation: gtk::Orientation::Horizontal, 294 | #[name(truebtn)] 295 | gtk::ToggleButton { 296 | set_label: "True", 297 | connect_toggled[sender] => move |x| { 298 | if x.is_active() { 299 | sender.input(OptPageMsg::UpdateConfMod(String::from("true"))) 300 | } 301 | } 302 | }, 303 | #[name(falsebtn)] 304 | gtk::ToggleButton { 305 | set_label: "False", 306 | set_group: Some(&truebtn), 307 | connect_toggled[sender] => move |x| { 308 | if x.is_active() { 309 | sender.input(OptPageMsg::UpdateConfMod(String::from("false"))) 310 | } 311 | } 312 | }, 313 | // #[name(nullbtn)] 314 | // gtk::ToggleButton { 315 | // set_label: "null", 316 | // set_group: Some(&truebtn), 317 | // } 318 | }, 319 | } 320 | }, 321 | 322 | gtk::Box { 323 | set_orientation: gtk::Orientation::Vertical, 324 | set_spacing: 10, 325 | #[name(value_box)] 326 | gtk::Box { 327 | set_orientation: gtk::Orientation::Horizontal, 328 | add_css_class: "header", 329 | add_css_class: "single-line", 330 | gtk::Label { 331 | set_halign: gtk::Align::Start, 332 | add_css_class: "heading", 333 | set_label: "Attribute Value", 334 | } 335 | }, 336 | gtk::Frame { 337 | add_css_class: "code", 338 | sourceview5::View { 339 | set_background_pattern: sourceview5::BackgroundPatternType::Grid, 340 | set_height_request: 100, 341 | set_editable: true, 342 | set_monospace: true, 343 | set_top_margin: 5, 344 | set_bottom_margin: 5, 345 | set_left_margin: 5, 346 | #[wrap(Some)] 347 | set_buffer: valuebuf = &sourceview5::Buffer { 348 | #[track(model.changed(OptPageModel::scheme()))] 349 | set_style_scheme: model.scheme.as_ref(), 350 | #[track(model.changed(OptPageModel::opt()))] 351 | set_text: { 352 | debug!("opt changing valuebuf to {:?}", model.conf); 353 | &model.conf 354 | }, 355 | #[track(model.changed(OptPageModel::conf()))] 356 | set_text: { 357 | debug!("conf changing valuebuf to {:?}", model.conf); 358 | &model.conf 359 | }, 360 | #[track(model.changed(OptPageModel::valuetracker()))] 361 | set_text: { 362 | debug!("valuetracker changing valuebuf to {:?}", model.modifiedconf); 363 | &model.modifiedconf 364 | }, 365 | connect_changed[sender] => move |x| { 366 | let (start, end) = x.bounds(); 367 | debug!("valuebuf changed to {:?}", x.text(&start, &end, true)); 368 | let text = x.text(&start, &end, true).to_string(); 369 | sender.input(OptPageMsg::UpdateConf(text)) 370 | } 371 | } 372 | }, 373 | }, 374 | gtk::Box { 375 | set_orientation: gtk::Orientation::Horizontal, 376 | set_spacing: 10, 377 | gtk::Button { 378 | set_label: "Reset", 379 | #[watch] 380 | set_sensitive: model.conf != model.modifiedconf, 381 | connect_clicked[sender] => move |_| { 382 | sender.input(OptPageMsg::ResetConf) 383 | } 384 | }, 385 | gtk::Button { 386 | set_label: "Clear", 387 | #[watch] 388 | set_sensitive: !model.modifiedconf.is_empty(), 389 | connect_clicked[sender] => move |_| { 390 | sender.input(OptPageMsg::ClearConf) 391 | } 392 | }, 393 | #[name(savestack)] 394 | gtk::Stack { 395 | set_halign: gtk::Align::End, 396 | set_hexpand: true, 397 | #[name(savebtn)] 398 | gtk::Button { 399 | set_label: "Save", 400 | add_css_class: "suggested-action", 401 | #[watch] 402 | set_sensitive: model.conf != model.modifiedconf, 403 | connect_clicked[sender] => move |_| { 404 | sender.input(OptPageMsg::SaveConf) 405 | }, 406 | }, 407 | #[name(spinner)] 408 | gtk::Spinner { 409 | #[watch] 410 | set_spinning: model.saving, 411 | }, 412 | }, 413 | } 414 | } 415 | } 416 | } 417 | } 418 | } 419 | 420 | fn pre_view() { 421 | info!("pre_view"); 422 | let set_val = || { 423 | debug!("SET VAL"); 424 | if let Some(x) = valuestack.visible_child() { 425 | let val = model.conf.as_str(); 426 | if x.eq(truefalse) { 427 | if val == "true" { 428 | truebtn.set_active(true); 429 | falsebtn.set_active(false); 430 | } else if val == "false" { 431 | truebtn.set_active(false); 432 | falsebtn.set_active(true); 433 | } else { 434 | truebtn.set_active(false); 435 | falsebtn.set_active(false); 436 | } 437 | } else if x.eq(number) { 438 | number.set_sensitive(false); 439 | if let Ok(x) = val.parse::() { 440 | number.set_value(x); 441 | } else { 442 | number.set_value(0.0); 443 | } 444 | number.set_sensitive(true); 445 | } else if x.eq(stringentry) { 446 | if let Some(x) = val.chars().next() { 447 | if let Some(y) = val.chars().last() { 448 | if x == '"' && y == '"' { 449 | if let Some(v) = val.get(1..val.len() - 1) { 450 | stringentry.set_sensitive(false); 451 | stringentry.set_text(v); 452 | stringentry.set_sensitive(true); 453 | return; 454 | } 455 | } 456 | } 457 | } 458 | stringentry.set_sensitive(false); 459 | stringentry.set_text(""); 460 | stringentry.set_sensitive(true); 461 | } else { 462 | warn!("Unhandled valuestack child {:?}", x); 463 | } 464 | } else { 465 | info!( 466 | "No simple value widget for type '{}'", 467 | model.data.op_type.to_string() 468 | ); 469 | } 470 | }; 471 | 472 | if model.changed(OptPageModel::opt()) { 473 | let optype = model.data.op_type.as_str(); 474 | valuestack.set_child_visible(true); 475 | match optype { 476 | "boolean" | "null or boolean" => valuestack.set_visible_child(truefalse), 477 | "signed integer" | "null or signed integer" => valuestack.set_visible_child(number), 478 | "string" | "null or string" | "string, not containing newlines or colons" => { 479 | valuestack.set_visible_child(stringentry) 480 | } 481 | _ => valuestack.set_child_visible(false), 482 | } 483 | if valuestack.is_child_visible() { 484 | set_val(); 485 | } 486 | } 487 | if model.changed(OptPageModel::resettracker()) { 488 | // Reset button is pressed 489 | set_val(); 490 | } 491 | if model.saving { 492 | savestack.set_visible_child(spinner) 493 | } else { 494 | savestack.set_visible_child(savebtn) 495 | } 496 | } 497 | 498 | fn init( 499 | _parent_window: Self::Init, 500 | root: &Self::Root, 501 | sender: ComponentSender, 502 | ) -> ComponentParts { 503 | let async_handler = SaveAsyncHandler::builder() 504 | .detach_worker(()) 505 | .forward(sender.input_sender(), identity); 506 | let model = OptPageModel { 507 | opt: vec![], //parent_window.position.clone(), 508 | refopt: vec![], //parent_window.refposition.clone(), 509 | data: OptionData::default(), 510 | conf: String::new(), 511 | modifiedconf: String::new(), 512 | saving: false, 513 | alloptions: vec![], //parent_window.data.keys().map(|x| x.to_string()).collect::>(), 514 | scheme: None, 515 | resettracker: 0, 516 | valuetracker: 0, 517 | async_handler, 518 | tracker: 0, 519 | }; 520 | 521 | let widgets = view_output!(); 522 | 523 | ComponentParts { model, widgets } 524 | } 525 | 526 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 527 | self.reset(); 528 | match msg { 529 | OptPageMsg::UpdateOption(data, opt, refopt, conf, alloptions) => { 530 | info!("OptPageMsg::UpdateOption"); 531 | self.update_conf(|x| x.clear()); 532 | self.update_modifiedconf(|x| x.clear()); 533 | self.set_data(*data); 534 | self.update_opt(|o| *o = opt.to_vec()); 535 | self.set_refopt(refopt); 536 | self.set_conf(conf.clone()); 537 | self.set_modifiedconf(conf); 538 | self.set_alloptions(alloptions); 539 | } 540 | OptPageMsg::UpdateConf(conf) => { 541 | info!("OptPageMsg::UpdateConf"); 542 | if conf != self.modifiedconf { 543 | self.set_modifiedconf(conf); 544 | } 545 | } 546 | OptPageMsg::UpdateConfMod(conf) => { 547 | info!("OptPageMsg::UpdateConfMod"); 548 | if conf != self.modifiedconf { 549 | self.set_modifiedconf(conf); 550 | self.update_valuetracker(|_| ()); // Simulate change to conf 551 | } 552 | } 553 | OptPageMsg::ResetConf => { 554 | info!("OptPageMsg::ResetConf"); 555 | let conf = self.conf.clone(); 556 | self.set_modifiedconf(conf); 557 | self.update_valuetracker(|_| ()); // Simulate change to conf 558 | self.update_resettracker(|_| ()); // Simulate reset 559 | } 560 | OptPageMsg::ClearConf => { 561 | info!("OptPageMsg::ClearConf"); 562 | self.set_modifiedconf(String::default()); 563 | self.update_valuetracker(|_| ()); // Simulate change to conf 564 | } 565 | OptPageMsg::SaveConf => { 566 | info!("OptPageMsg::SaveConf"); 567 | let opt = self.opt.join("."); 568 | let refopt = self.refopt.join("."); 569 | let mut conf = self.modifiedconf.clone(); 570 | while conf.ends_with('\n') || conf.ends_with(' ') { 571 | conf.pop(); 572 | } 573 | self.set_modifiedconf(conf.clone()); 574 | if conf.is_empty() { 575 | sender.input(OptPageMsg::DoneSaving(true, "true\n".to_string())); 576 | } else { 577 | self.set_saving(true); 578 | let _ = sender.output(AppMsg::SetBusy(true)); 579 | self.async_handler.emit(SaveAsyncHandlerMsg::SaveCheck( 580 | opt, 581 | refopt, 582 | conf, 583 | self.alloptions.to_vec(), 584 | )); 585 | } 586 | } 587 | OptPageMsg::DoneSaving(save, message) => { 588 | info!("OptPageMsg::DoneSaving"); 589 | if save { 590 | if message.eq("true\n") { 591 | //Save 592 | self.set_conf(self.modifiedconf.clone()); 593 | let _ = sender.output(AppMsg::EditOpt( 594 | self.opt.join("."), 595 | self.modifiedconf.clone(), 596 | )); 597 | self.update_resettracker(|_| ()); // Simulate reset 598 | } else { 599 | //Type mismatch 600 | let e = format!( 601 | "{} is not of type {}", 602 | self.modifiedconf, 603 | self.data.op_type.as_str() 604 | ); 605 | let _ = sender.output(AppMsg::SaveError(e)); 606 | } 607 | } else { 608 | //Error 609 | let _ = sender.output(AppMsg::SaveError(message)); 610 | } 611 | 612 | self.set_saving(false); 613 | let _ = sender.output(AppMsg::SetBusy(false)); 614 | } 615 | OptPageMsg::SetScheme(scheme) => { 616 | info!("OptPageMsg::SetScheme"); 617 | self.set_scheme(sourceview5::StyleSchemeManager::default().scheme(&scheme)); 618 | } 619 | } 620 | } 621 | } 622 | -------------------------------------------------------------------------------- /src/ui/preferencespage.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use adw::prelude::*; 3 | use nix_data::config::configfile::NixDataConfig; 4 | use relm4::*; 5 | use relm4_components::open_dialog::*; 6 | use std::path::PathBuf; 7 | 8 | #[tracker::track] 9 | #[derive(Debug)] 10 | pub struct PreferencesPageModel { 11 | prefwindow: adw::PreferencesWindow, 12 | configpath: PathBuf, 13 | origconfigpath: PathBuf, 14 | flake: Option, 15 | origflake: Option, 16 | flakearg: Option, 17 | origflakearg: Option, 18 | generations: Option, 19 | #[tracker::no_eq] 20 | open_dialog: Controller, 21 | #[tracker::no_eq] 22 | flake_file_dialog: Controller, 23 | error: bool, 24 | } 25 | 26 | #[derive(Debug)] 27 | pub enum PreferencesPageMsg { 28 | Show(NixDataConfig), 29 | ShowErr(NixDataConfig), 30 | Open, 31 | OpenFlake, 32 | SetConfigPath(PathBuf), 33 | SetFlakePath(Option), 34 | SetFlakeArg(Option), 35 | Close, 36 | Ignore, 37 | } 38 | 39 | #[relm4::component(pub)] 40 | impl SimpleComponent for PreferencesPageModel { 41 | type Init = gtk::Window; 42 | type Input = PreferencesPageMsg; 43 | type Output = AppMsg; 44 | type Widgets = PreferencesPageWidgets; 45 | 46 | view! { 47 | adw::PreferencesWindow { 48 | set_transient_for: Some(&parent_window), 49 | set_modal: true, 50 | set_search_enabled: false, 51 | connect_close_request[sender] => move |_| { 52 | sender.input(PreferencesPageMsg::Close); 53 | gtk::Inhibit(true) 54 | }, 55 | add = &adw::PreferencesPage { 56 | add = &adw::PreferencesGroup { 57 | add = &adw::ActionRow { 58 | set_title: "Configuration file", 59 | add_suffix = >k::Box { 60 | set_orientation: gtk::Orientation::Horizontal, 61 | set_halign: gtk::Align::End, 62 | set_valign: gtk::Align::Center, 63 | set_spacing: 10, 64 | gtk::Button { 65 | gtk::Box { 66 | set_orientation: gtk::Orientation::Horizontal, 67 | set_spacing: 5, 68 | gtk::Image { 69 | set_icon_name: Some("document-open-symbolic"), 70 | }, 71 | gtk::Label { 72 | #[watch] 73 | set_label: { 74 | let x = model.configpath.to_str().unwrap_or_default(); 75 | if x.is_empty() { 76 | "(None)" 77 | } else { 78 | x 79 | } 80 | } 81 | } 82 | }, 83 | connect_clicked[sender] => move |_| { 84 | sender.input(PreferencesPageMsg::Open); 85 | } 86 | }, 87 | } 88 | }, 89 | add = &adw::ActionRow { 90 | set_title: "Use nix flakes", 91 | add_suffix = >k::Switch { 92 | set_valign: gtk::Align::Center, 93 | connect_state_set[sender] => move |_, b| { 94 | if b { 95 | sender.input(PreferencesPageMsg::SetFlakePath(Some(PathBuf::new()))); 96 | } else { 97 | sender.input(PreferencesPageMsg::SetFlakePath(None)); 98 | sender.input(PreferencesPageMsg::SetFlakeArg(None)); 99 | } 100 | gtk::Inhibit(false) 101 | } @switched, 102 | #[track(model.changed(PreferencesPageModel::flake()))] 103 | #[block_signal(switched)] 104 | set_state: model.flake.is_some() 105 | } 106 | }, 107 | add = &adw::ActionRow { 108 | set_title: "Flake file", 109 | #[watch] 110 | set_visible: model.flake.is_some(), 111 | add_suffix = >k::Box { 112 | set_orientation: gtk::Orientation::Horizontal, 113 | set_halign: gtk::Align::End, 114 | set_valign: gtk::Align::Center, 115 | set_spacing: 10, 116 | gtk::Button { 117 | gtk::Box { 118 | set_orientation: gtk::Orientation::Horizontal, 119 | set_spacing: 5, 120 | gtk::Image { 121 | set_icon_name: Some("document-open-symbolic"), 122 | }, 123 | gtk::Label { 124 | #[watch] 125 | set_label: { 126 | let x = if let Some(f) = &model.flake { 127 | f.file_name().unwrap_or_default().to_str().unwrap_or_default() 128 | } else { 129 | "" 130 | }; 131 | if x.is_empty() { 132 | "(None)" 133 | } else { 134 | x 135 | } 136 | } 137 | } 138 | }, 139 | connect_clicked[sender] => move |_| { 140 | sender.input(PreferencesPageMsg::OpenFlake); 141 | } 142 | }, 143 | } 144 | }, 145 | add = &adw::EntryRow { 146 | #[watch] 147 | set_visible: model.flake.is_some(), 148 | set_title: "Flake arguments (--flake path/to/flake.nix#<THIS ENTRY>)", 149 | connect_changed[sender] => move |x| { 150 | sender.input(PreferencesPageMsg::SetFlakeArg({ 151 | let text = x.text().to_string(); 152 | if text.is_empty() { 153 | None 154 | } else { 155 | Some(text) 156 | }})); 157 | } @flakeentry, 158 | #[track(model.changed(PreferencesPageModel::flake()))] 159 | #[block_signal(flakeentry)] 160 | set_text: model.flakearg.as_ref().unwrap_or(&String::new()) 161 | } 162 | 163 | } 164 | } 165 | } 166 | } 167 | 168 | fn init( 169 | parent_window: Self::Init, 170 | root: &Self::Root, 171 | sender: ComponentSender, 172 | ) -> ComponentParts { 173 | let open_dialog = OpenDialog::builder() 174 | .transient_for_native(root) 175 | .launch(OpenDialogSettings::default()) 176 | .forward(sender.input_sender(), |response| match response { 177 | OpenDialogResponse::Accept(path) => PreferencesPageMsg::SetConfigPath(path), 178 | OpenDialogResponse::Cancel => PreferencesPageMsg::Ignore, 179 | }); 180 | let flake_file_dialog = OpenDialog::builder() 181 | .transient_for_native(root) 182 | .launch(OpenDialogSettings::default()) 183 | .forward(sender.input_sender(), |response| match response { 184 | OpenDialogResponse::Accept(path) => PreferencesPageMsg::SetFlakePath(Some(path)), 185 | OpenDialogResponse::Cancel => PreferencesPageMsg::Ignore, 186 | }); 187 | let model = PreferencesPageModel { 188 | prefwindow: root.clone(), 189 | configpath: PathBuf::new(), 190 | origconfigpath: PathBuf::new(), 191 | flake: None, 192 | origflake: None, 193 | flakearg: None, 194 | origflakearg: None, 195 | generations: None, 196 | open_dialog, 197 | flake_file_dialog, 198 | error: false, 199 | tracker: 0, 200 | }; 201 | 202 | let widgets = view_output!(); 203 | 204 | ComponentParts { model, widgets } 205 | } 206 | 207 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 208 | self.reset(); 209 | match msg { 210 | PreferencesPageMsg::Show(config) => { 211 | if let Some(systemconfig) = &config.systemconfig { 212 | self.configpath = PathBuf::from(systemconfig); 213 | self.origconfigpath = PathBuf::from(systemconfig); 214 | } 215 | self.set_flake(config.flake.as_ref().map(PathBuf::from)); 216 | self.origflake = self.flake.clone(); 217 | self.set_flakearg(config.flakearg); 218 | self.origflakearg = self.flakearg.clone(); 219 | self.generations = config.generations; 220 | self.prefwindow.show(); 221 | self.error = false; 222 | } 223 | PreferencesPageMsg::ShowErr(config) => { 224 | if let Some(systemconfig) = &config.systemconfig { 225 | self.configpath = PathBuf::from(systemconfig); 226 | self.origconfigpath = PathBuf::from(systemconfig); 227 | } 228 | self.set_flake(config.flake.as_ref().map(PathBuf::from)); 229 | self.origflake = self.flake.clone(); 230 | self.set_flakearg(config.flakearg); 231 | self.origflakearg = self.flakearg.clone(); 232 | self.prefwindow.present(); 233 | self.error = true; 234 | } 235 | PreferencesPageMsg::Open => self.open_dialog.emit(OpenDialogMsg::Open), 236 | PreferencesPageMsg::OpenFlake => self.flake_file_dialog.emit(OpenDialogMsg::Open), 237 | PreferencesPageMsg::SetConfigPath(path) => { 238 | self.configpath = path; 239 | } 240 | PreferencesPageMsg::SetFlakePath(path) => { 241 | self.flake = path; 242 | } 243 | PreferencesPageMsg::SetFlakeArg(arg) => { 244 | self.flakearg = arg; 245 | } 246 | PreferencesPageMsg::Close => { 247 | if !self.configpath.eq(&self.origconfigpath) 248 | || !self.flake.eq(&self.origflake) 249 | || !self.flakearg.eq(&self.origflakearg) 250 | || self.error 251 | { 252 | let _ = sender.output(AppMsg::SetConfig(NixDataConfig { 253 | systemconfig: Some(self.configpath.to_string_lossy().to_string()), 254 | flake: self.flake.as_ref().map(|x| x.to_string_lossy().to_string()), 255 | flakearg: self.flakearg.clone(), 256 | generations: self.generations, 257 | })); 258 | } 259 | self.prefwindow.hide(); 260 | } 261 | _ => {} 262 | } 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/ui/quitdialog.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use adw::prelude::*; 3 | use relm4::*; 4 | 5 | pub struct QuitCheckModel { 6 | hidden: bool, 7 | busy: bool, 8 | } 9 | 10 | #[derive(Debug)] 11 | pub enum QuitCheckMsg { 12 | Show, 13 | Save, 14 | Rebuild, 15 | Quit, 16 | } 17 | 18 | #[relm4::component(pub)] 19 | impl SimpleComponent for QuitCheckModel { 20 | type Init = gtk::Window; 21 | type Input = QuitCheckMsg; 22 | type Output = AppMsg; 23 | type Widgets = QuitCheckWidgets; 24 | 25 | view! { 26 | dialog = gtk::MessageDialog { 27 | set_transient_for: Some(&init), 28 | set_modal: true, 29 | #[watch] 30 | set_visible: !model.hidden, 31 | set_resizable: false, 32 | #[watch] 33 | set_sensitive: !model.busy, 34 | set_text: Some("Save Changes?"), 35 | set_secondary_text: Some("Unsaved changes will be lost. You should rebuild your system now to ensure you configured everything properly. You can also save your configuration, however is is possible that your configuration is save in an unbuildable state."), 36 | set_default_width: 500, 37 | add_button: ("Quit", gtk::ResponseType::Close), 38 | add_button: ("Save", gtk::ResponseType::Reject), 39 | add_button: ("Rebuild", gtk::ResponseType::Accept), 40 | connect_response[sender] => move |_, resp| { 41 | sender.input(match resp { 42 | gtk::ResponseType::Accept => QuitCheckMsg::Rebuild, 43 | gtk::ResponseType::Reject => QuitCheckMsg::Save, 44 | gtk::ResponseType::Close => QuitCheckMsg::Quit, 45 | _ => unreachable!(), 46 | }); 47 | } 48 | } 49 | } 50 | 51 | fn init( 52 | init: Self::Init, 53 | root: &Self::Root, 54 | sender: ComponentSender, 55 | ) -> ComponentParts { 56 | let model = QuitCheckModel { 57 | hidden: true, 58 | busy: false, 59 | }; 60 | 61 | let widgets = view_output!(); 62 | 63 | let rebuild_widget = widgets 64 | .dialog 65 | .widget_for_response(gtk::ResponseType::Accept) 66 | .expect("No button for accept response set"); 67 | rebuild_widget.add_css_class("suggested-action"); 68 | let save_widget = widgets 69 | .dialog 70 | .widget_for_response(gtk::ResponseType::Reject) 71 | .expect("No button for reject response set"); 72 | save_widget.add_css_class("warning"); 73 | let quit_widget = widgets 74 | .dialog 75 | .widget_for_response(gtk::ResponseType::Close) 76 | .expect("No button for close response set"); 77 | quit_widget.add_css_class("destructive-action"); 78 | 79 | ComponentParts { model, widgets } 80 | } 81 | 82 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 83 | match msg { 84 | QuitCheckMsg::Show => { 85 | self.hidden = false; 86 | self.busy = false; 87 | } 88 | QuitCheckMsg::Save => { 89 | self.busy = true; 90 | let _ = sender.output(AppMsg::SaveQuit); 91 | } 92 | QuitCheckMsg::Rebuild => { 93 | self.hidden = true; 94 | let _ = sender.output(AppMsg::Rebuild); 95 | } 96 | QuitCheckMsg::Quit => { 97 | relm4::main_application().quit(); 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/ui/rebuild.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use crate::config::LIBEXECDIR; 3 | use adw::prelude::*; 4 | use gtk::{gio, glib}; 5 | use relm4::*; 6 | use std::io::Write; 7 | use std::process::Command; 8 | use std::process::*; 9 | use vte::{TerminalExt, TerminalExtManual}; 10 | 11 | #[tracker::track] 12 | pub struct RebuildModel { 13 | hidden: bool, 14 | status: RebuildStatus, 15 | config: String, 16 | path: String, 17 | flake: Option, 18 | scheme: Option, 19 | terminal: vte::Terminal, 20 | } 21 | 22 | #[derive(Debug)] 23 | pub enum RebuildMsg { 24 | Rebuild(String, String, Option), 25 | FinishSuccess, 26 | FinishError(Option), 27 | WriteConfig(String, String, bool), 28 | KeepEditing, 29 | Reset, 30 | Save, 31 | Close, 32 | SetScheme(String), 33 | WriteConfigQuit(String, String), 34 | Quit, 35 | } 36 | 37 | #[derive(PartialEq)] 38 | enum RebuildStatus { 39 | Building, 40 | Success, 41 | Error, 42 | } 43 | 44 | #[relm4::component(pub)] 45 | impl SimpleComponent for RebuildModel { 46 | type Init = gtk::Window; 47 | type Input = RebuildMsg; 48 | type Output = AppMsg; 49 | type Widgets = RebuildWidgets; 50 | 51 | view! { 52 | dialog = adw::Window { 53 | set_transient_for: Some(&parent_window), 54 | set_modal: true, 55 | #[track(model.changed(RebuildModel::hidden()))] 56 | set_default_width: 500, 57 | #[track(model.changed(RebuildModel::hidden()))] 58 | set_default_height: 200,//295), 59 | set_resizable: true, 60 | #[watch] 61 | set_visible: !model.hidden, 62 | add_css_class: "dialog", 63 | add_css_class: "message", 64 | gtk::Box { 65 | set_orientation: gtk::Orientation::Vertical, 66 | #[name(statusstack)] 67 | gtk::Stack { 68 | set_margin_top: 20, 69 | set_transition_type: gtk::StackTransitionType::Crossfade, 70 | set_vhomogeneous: false, 71 | #[name(building)] 72 | gtk::Box { 73 | set_orientation: gtk::Orientation::Vertical, 74 | set_spacing: 10, 75 | gtk::Spinner { 76 | set_spinning: true, 77 | set_height_request: 60, 78 | }, 79 | gtk::Label { 80 | set_label: "Building...", 81 | add_css_class: "title-1", 82 | }, 83 | }, 84 | #[name(success)] 85 | gtk::Box { 86 | set_orientation: gtk::Orientation::Vertical, 87 | set_spacing: 10, 88 | gtk::Image { 89 | add_css_class: "success", 90 | set_icon_name: Some("object-select-symbolic"), 91 | set_pixel_size: 128, 92 | }, 93 | gtk::Label { 94 | set_label: "Done!", 95 | add_css_class: "title-1", 96 | }, 97 | gtk::Label { 98 | set_label: "Rebuild successful!", 99 | add_css_class: "dim-label", 100 | } 101 | }, 102 | #[name(error)] 103 | gtk::Box { 104 | set_orientation: gtk::Orientation::Vertical, 105 | set_spacing: 10, 106 | gtk::Image { 107 | add_css_class: "error", 108 | set_icon_name: Some("dialog-error-symbolic"), 109 | set_pixel_size: 128, 110 | }, 111 | gtk::Label { 112 | set_label: "Error!", 113 | add_css_class: "title-1", 114 | }, 115 | gtk::Label { 116 | set_label: "Rebuild failed! See below for error message.", 117 | add_css_class: "dim-label", 118 | } 119 | } 120 | }, 121 | gtk::Frame { 122 | set_margin_all: 20, 123 | #[name(scrollwindow)] 124 | gtk::ScrolledWindow { 125 | set_max_content_height: 500, 126 | set_min_content_height: 100, 127 | #[local_ref] 128 | terminal -> vte::Terminal { 129 | set_vexpand: true, 130 | set_hexpand: true, 131 | set_input_enabled: false, 132 | connect_child_exited[sender] => move |_term, status| { 133 | if status == 0 { 134 | sender.input(RebuildMsg::FinishSuccess); 135 | } else { 136 | sender.input(RebuildMsg::FinishError(None)); 137 | } 138 | } 139 | } 140 | } 141 | }, 142 | gtk::Box { 143 | add_css_class: "dialog-action-area", 144 | set_orientation: gtk::Orientation::Horizontal, 145 | set_homogeneous: true, 146 | #[track(model.changed(RebuildModel::status()))] 147 | set_visible: model.status != RebuildStatus::Building, 148 | gtk::Button { 149 | set_label: "Close", 150 | #[track(model.changed(RebuildModel::status()))] 151 | set_visible: model.status == RebuildStatus::Success, 152 | connect_clicked[sender] => move |_| { 153 | sender.input(RebuildMsg::Save) 154 | } 155 | }, 156 | gtk::Button { 157 | add_css_class: "destructive-action", 158 | set_label: "Save Anyways", 159 | #[track(model.changed(RebuildModel::status()))] 160 | set_visible: model.status == RebuildStatus::Error, 161 | connect_clicked[sender] => move |_| { 162 | sender.input(RebuildMsg::Save) 163 | } 164 | }, 165 | gtk::Button { 166 | set_label: "Reset Changes", 167 | #[track(model.changed(RebuildModel::status()))] 168 | set_visible: model.status == RebuildStatus::Error, 169 | connect_clicked[sender] => move |_| { 170 | sender.input(RebuildMsg::Reset) 171 | } 172 | }, 173 | gtk::Button { 174 | set_label: "Keep Editing", 175 | #[track(model.changed(RebuildModel::status()))] 176 | set_visible: model.status == RebuildStatus::Error, 177 | connect_clicked[sender] => move |_| { 178 | sender.input(RebuildMsg::KeepEditing) 179 | } 180 | } 181 | } 182 | } 183 | } 184 | } 185 | 186 | fn pre_view() { 187 | match model.status { 188 | RebuildStatus::Building => { 189 | statusstack.set_visible_child(building); 190 | } 191 | RebuildStatus::Success => statusstack.set_visible_child(success), 192 | RebuildStatus::Error => statusstack.set_visible_child(error), 193 | } 194 | } 195 | 196 | fn init( 197 | parent_window: Self::Init, 198 | root: &Self::Root, 199 | sender: ComponentSender, 200 | ) -> ComponentParts { 201 | let model = RebuildModel { 202 | hidden: true, 203 | status: RebuildStatus::Building, 204 | config: String::new(), 205 | path: String::new(), 206 | flake: None, 207 | scheme: None, 208 | terminal: vte::Terminal::new(), 209 | tracker: 0, 210 | }; 211 | 212 | let terminal = &model.terminal; 213 | let widgets = view_output!(); 214 | 215 | ComponentParts { model, widgets } 216 | } 217 | 218 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 219 | self.reset(); 220 | match msg { 221 | RebuildMsg::Rebuild(f, path, flake) => { 222 | self.update_hidden(|x| *x = false); 223 | self.set_config(f.to_string()); 224 | self.set_path(path.to_string()); 225 | self.set_flake(flake.clone()); 226 | self.set_status(RebuildStatus::Building); 227 | if let Some(flake) = flake { 228 | self.terminal.spawn_async( 229 | vte::PtyFlags::DEFAULT, 230 | Some("/"), 231 | &[ 232 | "/usr/bin/env", 233 | "pkexec", 234 | &format!("{}/nce-helper", LIBEXECDIR), 235 | "write-rebuild", 236 | "--content", 237 | &f, 238 | "--path", 239 | &path, 240 | "--", 241 | "switch", 242 | "--flake", 243 | &flake, 244 | ], 245 | &[], 246 | glib::SpawnFlags::DEFAULT, 247 | || (), 248 | -1, 249 | gio::Cancellable::NONE, 250 | |_, _, _| (), 251 | ); 252 | } else { 253 | self.terminal.spawn_async( 254 | vte::PtyFlags::DEFAULT, 255 | Some("/"), 256 | &[ 257 | "/usr/bin/env", 258 | "pkexec", 259 | &format!("{}/nce-helper", LIBEXECDIR), 260 | "write-rebuild", 261 | "--content", 262 | &f, 263 | "--path", 264 | &path, 265 | "--", 266 | "switch", 267 | "-I", 268 | &format!("nixos-config={}", path), 269 | ], 270 | &[], 271 | glib::SpawnFlags::DEFAULT, 272 | || (), 273 | -1, 274 | gio::Cancellable::NONE, 275 | |_, _, _| (), 276 | ); 277 | } 278 | } 279 | RebuildMsg::FinishSuccess => { 280 | self.set_status(RebuildStatus::Success); 281 | } 282 | RebuildMsg::FinishError(_msg) => { 283 | self.update_hidden(|x| *x = false); 284 | self.set_status(RebuildStatus::Error); 285 | } 286 | RebuildMsg::KeepEditing => { 287 | sender.input(RebuildMsg::WriteConfig( 288 | self.config.to_string(), 289 | self.path.to_string(), 290 | false, 291 | )); 292 | sender.input(RebuildMsg::Close); 293 | } 294 | RebuildMsg::Reset => { 295 | let _ = sender.output(AppMsg::ResetConfig); 296 | sender.input(RebuildMsg::Close); 297 | } 298 | RebuildMsg::Save => { 299 | let _ = sender.output(AppMsg::SaveConfig); 300 | sender.input(RebuildMsg::Close); 301 | } 302 | RebuildMsg::Close => { 303 | self.terminal.reset(true, true); 304 | self.terminal.spawn_async( 305 | vte::PtyFlags::DEFAULT, 306 | Some("/"), 307 | &["/usr/bin/env", "clear"], 308 | &[], 309 | glib::SpawnFlags::DEFAULT, 310 | || (), 311 | -1, 312 | gio::Cancellable::NONE, 313 | |_, _, _| (), 314 | ); 315 | self.update_hidden(|x| *x = true); 316 | } 317 | RebuildMsg::SetScheme(scheme) => { 318 | self.set_scheme(sourceview5::StyleSchemeManager::default().scheme(&scheme)); 319 | } 320 | RebuildMsg::WriteConfigQuit(f, path) => { 321 | sender.input(RebuildMsg::WriteConfig(f, path, true)); 322 | } 323 | RebuildMsg::WriteConfig(f, path, quit) => { 324 | let mut writecmd = Command::new("pkexec") 325 | .arg(&format!("{}/nce-helper", LIBEXECDIR)) 326 | .arg("config") 327 | .arg("--output") 328 | .arg(path) 329 | .stdin(Stdio::piped()) 330 | .spawn() 331 | .unwrap(); 332 | writecmd 333 | .stdin 334 | .as_mut() 335 | .ok_or("stdin not available") 336 | .unwrap() 337 | .write_all(f.as_bytes()) 338 | .unwrap(); 339 | writecmd.wait().unwrap(); 340 | if quit { 341 | sender.input(RebuildMsg::Quit); 342 | } 343 | } 344 | RebuildMsg::Quit => { 345 | let _ = sender.output(AppMsg::Close); 346 | } 347 | } 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /src/ui/savechecking.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use crate::ui::optionpage::OptPageMsg; 3 | use adw::prelude::*; 4 | use log::{debug, info}; 5 | use relm4::*; 6 | use sourceview5::prelude::*; 7 | use std::{path::Path, process::Command}; 8 | 9 | pub struct SaveAsyncHandler; 10 | 11 | #[derive(Debug)] 12 | pub enum SaveAsyncHandlerMsg { 13 | SaveCheck(String, String, String, Vec), 14 | } 15 | 16 | impl Worker for SaveAsyncHandler { 17 | type Init = (); 18 | type Input = SaveAsyncHandlerMsg; 19 | type Output = OptPageMsg; 20 | 21 | fn init(_params: Self::Init, _sender: relm4::ComponentSender) -> Self { 22 | Self 23 | } 24 | 25 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 26 | match msg { 27 | SaveAsyncHandlerMsg::SaveCheck(opt, refopt, conf, alloptions) => { 28 | info!("Received SaveCheck message"); 29 | debug!("opt: {}\nrefopt: {}", opt, refopt); 30 | // For users.users..autoSubUidGidRange 31 | // (options.users.users.type.getSubOptions []).autoSubUidGidRange.type.check 32 | let checkcmd = { 33 | let p = refopt.split('.').collect::>(); 34 | let mut r: Vec> = vec![vec![]]; 35 | let mut indexvec: Vec = vec![]; 36 | let mut j = 0; 37 | for i in 0..p.len() { 38 | if p[i] == "*" || p[i] == "" { 39 | r.push(vec![]); 40 | if let Ok(x) = opt.split('.').collect::>()[i].parse::() { 41 | indexvec.push(x); 42 | } 43 | j += 1; 44 | } else if alloptions.contains(&p[..i].join(".")) && i + 1 < p.len() 45 | /* Check if option exists */ 46 | { 47 | r.push(vec![]); 48 | j += 1; 49 | r[j].push(p[i].to_string()); 50 | } else { 51 | r[j].push(p[i].to_string()); 52 | } 53 | } 54 | let mut s = format!("options.{}", r[0].join(".")); 55 | for y in r[1..].iter() { 56 | s = format!("({}.type.getSubOptions []).{}", s, y.join(".")); 57 | } 58 | format!("{}.type.check", s) 59 | }; 60 | let output = 61 | if Path::new("/nix/var/nix/profiles/per-user/root/channels/nixos").exists() { 62 | Command::new("nix-instantiate") 63 | .arg("--eval") 64 | .arg("--expr") 65 | .arg(format!( 66 | "with import {{}}; {} ({})", 67 | checkcmd, conf 68 | )) 69 | .output() 70 | } else { 71 | match Command::new("nix").arg("eval").arg("nixpkgs#path").output() { 72 | Ok(nixpath) => { 73 | let nixospath = format!( 74 | "{}/nixos/lib/eval-config.nix", 75 | String::from_utf8_lossy(&nixpath.stdout).trim() 76 | ); 77 | Command::new("nix-instantiate") 78 | .arg("--eval") 79 | .arg("--expr") 80 | .arg(format!( 81 | "with import {} {{ modules = []; }}; {} ({})", 82 | nixospath, checkcmd, conf 83 | )) 84 | .output() 85 | } 86 | Err(e) => Err(e), 87 | } 88 | }; 89 | let (b, s) = match output { 90 | Ok(output) => { 91 | if output.status.success() { 92 | let output = String::from_utf8(output.stdout).unwrap(); 93 | (true, output) 94 | } else { 95 | let output = String::from_utf8(output.stderr).unwrap(); 96 | (false, output) 97 | } 98 | } 99 | Err(e) => (false, e.to_string()), 100 | }; 101 | let _ = sender.output(OptPageMsg::DoneSaving(b, s)); 102 | } 103 | } 104 | } 105 | } 106 | 107 | pub struct SaveErrorModel { 108 | hidden: bool, 109 | msg: String, 110 | scheme: Option, 111 | } 112 | 113 | #[derive(Debug)] 114 | pub enum SaveErrorMsg { 115 | Show(String), 116 | SaveError, 117 | Reset, 118 | Cancel, 119 | SetScheme(String), 120 | } 121 | 122 | #[relm4::component(pub)] 123 | impl SimpleComponent for SaveErrorModel { 124 | type Init = gtk::Window; 125 | type Input = SaveErrorMsg; 126 | type Output = AppMsg; 127 | type Widgets = SaveErrorWidgets; 128 | 129 | view! { 130 | dialog = gtk::MessageDialog { 131 | set_transient_for: Some(&parent_window), 132 | set_modal: true, 133 | #[watch] 134 | set_visible: !model.hidden, 135 | set_text: Some("Invalid configuration"), 136 | set_secondary_text: Some("Please fix the errors and try again."), 137 | #[watch] 138 | set_default_height: -1, 139 | set_default_width: 500, 140 | add_button: ("Keep changes", gtk::ResponseType::DeleteEvent), 141 | add_button: ("Reset", gtk::ResponseType::Reject), 142 | add_button: ("Edit", gtk::ResponseType::Cancel), 143 | connect_response[sender] => move |_, resp| { 144 | sender.input(match resp { 145 | gtk::ResponseType::DeleteEvent => SaveErrorMsg::SaveError, 146 | gtk::ResponseType::Reject => SaveErrorMsg::Reset, 147 | gtk::ResponseType::Cancel => SaveErrorMsg::Cancel, 148 | _ => unreachable!(), 149 | }); 150 | }, 151 | } 152 | } 153 | 154 | additional_fields! { 155 | frame: gtk::Frame, 156 | msgbuf: sourceview5::Buffer, 157 | } 158 | 159 | fn init( 160 | parent_window: Self::Init, 161 | root: &Self::Root, 162 | sender: ComponentSender, 163 | ) -> ComponentParts { 164 | let model = SaveErrorModel { 165 | hidden: true, 166 | msg: String::default(), 167 | scheme: None, 168 | }; 169 | 170 | view! { 171 | frame = gtk::Frame { 172 | set_margin_start: 20, 173 | set_margin_end: 20, 174 | gtk::ScrolledWindow { 175 | set_vscrollbar_policy: gtk::PolicyType::Never, 176 | sourceview5::View { 177 | set_vexpand: true, 178 | set_editable: false, 179 | set_cursor_visible: false, 180 | set_monospace: true, 181 | set_top_margin: 5, 182 | set_bottom_margin: 5, 183 | set_left_margin: 5, 184 | #[wrap(Some)] 185 | set_buffer: msgbuf = &sourceview5::Buffer { 186 | #[track(model.scheme)] 187 | set_style_scheme: model.scheme.as_ref(), 188 | }, 189 | } 190 | } 191 | } 192 | } 193 | 194 | let widgets = view_output!(); 195 | widgets.dialog.content_area().append(&widgets.frame); 196 | 197 | let accept_widget = widgets 198 | .dialog 199 | .widget_for_response(gtk::ResponseType::DeleteEvent) 200 | .expect("No button for accept response set"); 201 | accept_widget.add_css_class("destructive-action"); 202 | ComponentParts { model, widgets } 203 | } 204 | 205 | fn pre_view() { 206 | msgbuf.set_text(&model.msg); 207 | } 208 | 209 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 210 | match msg { 211 | SaveErrorMsg::Show(s) => { 212 | self.hidden = false; 213 | self.msg = s; 214 | } 215 | SaveErrorMsg::SaveError => { 216 | self.hidden = true; 217 | let _ = sender.output(AppMsg::SaveWithError); 218 | } 219 | SaveErrorMsg::Reset => { 220 | self.hidden = true; 221 | let _ = sender.output(AppMsg::SaveErrorReset); 222 | } 223 | SaveErrorMsg::Cancel => self.hidden = true, 224 | SaveErrorMsg::SetScheme(scheme) => { 225 | self.scheme = sourceview5::StyleSchemeManager::default().scheme(&scheme); 226 | } 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/ui/searchentry.rs: -------------------------------------------------------------------------------- 1 | use super::window::*; 2 | use adw::prelude::*; 3 | use relm4::{factory::*, *}; 4 | use std::collections::HashMap; 5 | 6 | pub struct SearchEntryModel { 7 | hidden: bool, 8 | position: Vec, 9 | data: FactoryVecDeque, 10 | nameopts: FactoryVecDeque, 11 | customopt: Vec, 12 | } 13 | 14 | #[derive(Debug)] 15 | pub enum SearchEntryMsg { 16 | Show(Vec, Vec), 17 | Close, 18 | Save(Option>), 19 | SetName(String, usize), 20 | } 21 | 22 | #[relm4::component(pub)] 23 | impl SimpleComponent for SearchEntryModel { 24 | type Init = gtk::Window; 25 | type Input = SearchEntryMsg; 26 | type Output = AppMsg; 27 | type Widgets = SearchEntryWidgets; 28 | 29 | view! { 30 | window = gtk::Dialog { 31 | set_default_height: -1, 32 | #[wrap(Some)] 33 | set_titlebar = &adw::HeaderBar { 34 | add_css_class: "flat" 35 | }, 36 | set_default_width: 500, 37 | set_resizable: false, 38 | set_transient_for: Some(&parent_window), 39 | set_modal: true, 40 | #[watch] 41 | set_visible: !model.hidden, 42 | connect_close_request[sender] => move |_| { 43 | sender.input(SearchEntryMsg::Close); 44 | gtk::Inhibit(true) 45 | }, 46 | connect_visible_notify => move |x| { 47 | if x.get_visible() { 48 | x.grab_focus(); 49 | } 50 | }, 51 | #[name(main_box)] 52 | gtk::Box { 53 | set_orientation: gtk::Orientation::Vertical, 54 | adw::Clamp { 55 | gtk::Box { 56 | set_margin_start: 15, 57 | set_margin_end: 15, 58 | set_spacing: 15, 59 | set_orientation: gtk::Orientation::Vertical, 60 | adw::PreferencesGroup { 61 | #[local_ref] 62 | add = datalistbox -> gtk::ListBox { 63 | add_css_class: "boxed-list", 64 | }, 65 | #[watch] 66 | set_visible: !model.data.is_empty(), 67 | }, 68 | adw::PreferencesGroup { 69 | #[local_ref] 70 | add = nameoptslistbox -> gtk::ListBox { 71 | set_margin_bottom: 15, 72 | add_css_class: "boxed-list", 73 | append = &adw::ActionRow { 74 | #[watch] 75 | set_title: &model.customopt.join("."), 76 | #[watch] 77 | set_sensitive: !model.customopt.contains(&String::from("<name>")), 78 | set_selectable: false, 79 | set_activatable: true, 80 | add_suffix = >k::Image { 81 | set_icon_name: Some("list-add-symbolic"), 82 | add_css_class: "accent", 83 | }, 84 | connect_activated[sender] => move |_| { 85 | sender.input(SearchEntryMsg::Save(None)); 86 | } 87 | } 88 | } 89 | }, 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | fn init( 97 | parent_window: Self::Init, 98 | root: &Self::Root, 99 | sender: ComponentSender, 100 | ) -> ComponentParts { 101 | let model = SearchEntryModel { 102 | hidden: true, 103 | position: Vec::default(), 104 | data: FactoryVecDeque::new(gtk::ListBox::new(), sender.input_sender()), 105 | nameopts: FactoryVecDeque::new(gtk::ListBox::new(), sender.input_sender()), 106 | customopt: Vec::default(), 107 | }; 108 | let datalistbox = model.data.widget(); 109 | let nameoptslistbox = model.nameopts.widget(); 110 | 111 | let widgets = view_output!(); 112 | 113 | ComponentParts { model, widgets } 114 | } 115 | 116 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 117 | let mut data_guard = self.data.guard(); 118 | let mut nameopts_guard = self.nameopts.guard(); 119 | match msg { 120 | SearchEntryMsg::Show(pos, optdata) => { 121 | data_guard.clear(); 122 | nameopts_guard.clear(); 123 | for v in optdata { 124 | data_guard.push_back(v); 125 | } 126 | for i in 0..pos.len() { 127 | if pos[i] == "" { 128 | let mut op = pos.to_vec(); 129 | op[i] = "<name>".to_string(); 130 | nameopts_guard 131 | .push_back((op.join(".").replace("", "<name>"), i)); 132 | } 133 | } 134 | self.position = pos.clone(); 135 | self.customopt = pos 136 | .iter() 137 | .map(|x| x.replace("", "<name>")) 138 | .collect(); 139 | self.hidden = false; 140 | } 141 | SearchEntryMsg::Close => { 142 | self.hidden = true; 143 | data_guard.clear(); 144 | } 145 | SearchEntryMsg::Save(dest) => { 146 | self.hidden = true; 147 | let mut n: HashMap = HashMap::new(); 148 | if dest.is_none() { 149 | for i in 0..self.position.len() { 150 | if self.position[i] == "" { 151 | let mut existing = vec![]; 152 | for i in 0..data_guard.len() { 153 | let dvec = &data_guard.get(i).unwrap().value; 154 | if !existing.contains(&dvec[i].to_string()) 155 | && dvec[..i] == self.customopt[..i] 156 | { 157 | existing.push(dvec[i].to_string()); 158 | } 159 | } 160 | if !existing.contains(&self.customopt[i]) { 161 | let _ = sender.output(AppMsg::AddNameAttr( 162 | Some(self.customopt[..i].join(".")), 163 | self.customopt[i].clone(), 164 | )); 165 | } 166 | } else if self.position[i] == "*" { 167 | let mut existing = vec![]; 168 | for i in 0..data_guard.len() { 169 | let dvec = &data_guard.get(i).unwrap().value; 170 | if dvec[..i] == self.customopt[..i] { 171 | if let Some(v) = dvec.get(i) { 172 | if let Ok(x) = v.parse::() { 173 | if !existing.contains(&x) { 174 | existing.push(x); 175 | } 176 | } 177 | } 178 | } 179 | } 180 | existing.sort_unstable(); 181 | let num = if let Some(x) = existing.last() { 182 | *x + 1 183 | } else { 184 | 0 185 | }; 186 | n.insert(i, num); 187 | let _ = sender.output(AppMsg::AddStar(self.customopt[..i].join("."))); 188 | } 189 | } 190 | for (k, v) in n { 191 | self.customopt[k] = v.to_string(); 192 | } 193 | } 194 | data_guard.clear(); 195 | let _ = sender.output(AppMsg::OpenSearchOption( 196 | if let Some(x) = dest { 197 | x 198 | } else { 199 | self.customopt.to_vec() 200 | }, 201 | self.position.to_vec(), 202 | )); 203 | } 204 | SearchEntryMsg::SetName(v, i) => { 205 | if self.position.get(i).is_some() { 206 | if v.is_empty() { 207 | self.customopt[i] = String::from("<name>"); 208 | } else { 209 | self.customopt[i] = v; 210 | } 211 | } 212 | } 213 | } 214 | } 215 | } 216 | 217 | #[derive(Debug, PartialEq)] 218 | struct SearchEntryOption { 219 | value: Vec, 220 | } 221 | 222 | #[derive(Debug)] 223 | enum SearchEntryOptionOutput { 224 | Save(Vec), 225 | } 226 | 227 | #[relm4::factory] 228 | impl FactoryComponent for SearchEntryOption { 229 | type Init = String; 230 | type Input = (); 231 | type Output = SearchEntryOptionOutput; 232 | type Widgets = CounterWidgets; 233 | type ParentWidget = gtk::ListBox; 234 | type ParentInput = SearchEntryMsg; 235 | type CommandOutput = (); 236 | 237 | view! { 238 | adw::ActionRow { 239 | #[watch] 240 | set_title: &self.value.join("."), 241 | set_selectable: false, 242 | set_activatable: true, 243 | connect_activated[sender, value = self.value.clone()] => move |_| { 244 | sender.output(SearchEntryOptionOutput::Save(value.to_vec())); 245 | } 246 | } 247 | } 248 | 249 | fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 250 | let v = value 251 | .split('.') 252 | .map(|x| x.to_string()) 253 | .collect::>(); 254 | Self { value: v } 255 | } 256 | 257 | fn output_to_parent_input(output: Self::Output) -> Option { 258 | Some(match output { 259 | SearchEntryOptionOutput::Save(v) => SearchEntryMsg::Save(Some(v)), 260 | }) 261 | } 262 | } 263 | 264 | #[derive(Debug, PartialEq)] 265 | struct SearchNameEntryOption { 266 | value: String, 267 | index: usize, 268 | } 269 | 270 | #[derive(Debug)] 271 | enum SearchNameEntryOptionOutput { 272 | SetName(String, usize), 273 | } 274 | 275 | #[relm4::factory] 276 | impl FactoryComponent for SearchNameEntryOption { 277 | type Init = (String, usize); 278 | type Input = (); 279 | type Output = SearchNameEntryOptionOutput; 280 | type Widgets = SearchNameWidgets; 281 | type ParentWidget = gtk::ListBox; 282 | type ParentInput = SearchEntryMsg; 283 | type CommandOutput = (); 284 | 285 | view! { 286 | adw::ActionRow { 287 | #[watch] 288 | set_title: &self.value, 289 | add_suffix = >k::Entry { 290 | set_valign: gtk::Align::Center, 291 | set_placeholder_text: Some(""), 292 | set_buffer = >k::EntryBuffer { 293 | connect_text_notify[sender, index = self.index] => move |x| { 294 | sender.output(SearchNameEntryOptionOutput::SetName(x.text(), index)); 295 | } 296 | } 297 | }, 298 | set_selectable: false, 299 | set_activatable: false, 300 | } 301 | } 302 | 303 | fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 304 | Self { 305 | value: value.0, 306 | index: value.1, 307 | } 308 | } 309 | 310 | fn output_to_parent_input(output: Self::Output) -> Option { 311 | Some(match output { 312 | SearchNameEntryOptionOutput::SetName(x, i) => SearchEntryMsg::SetName(x, i), 313 | }) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/ui/searchfactory.rs: -------------------------------------------------------------------------------- 1 | use super::searchpage::SearchPageMsg; 2 | use adw::prelude::*; 3 | use relm4::{factory::*, *}; 4 | 5 | #[derive(Default, Debug, PartialEq, Eq)] 6 | pub struct SearchOption { 7 | pub value: Vec, 8 | pub configured: bool, 9 | pub modified: bool, 10 | } 11 | 12 | #[relm4::factory(pub)] 13 | impl FactoryComponent for SearchOption { 14 | type Init = SearchOption; 15 | type Input = (); 16 | type Output = (); 17 | type Widgets = SearchOptionWidgets; 18 | type ParentWidget = gtk::ListBox; 19 | type ParentInput = SearchPageMsg; 20 | type CommandOutput = (); 21 | 22 | view! { 23 | adw::PreferencesRow { 24 | #[wrap(Some)] 25 | set_child = >k::Box { 26 | set_orientation: gtk::Orientation::Horizontal, 27 | set_spacing: 6, 28 | set_margin_all: 15, 29 | gtk::Label { 30 | set_text: &self.value.join("."), 31 | }, 32 | gtk::Separator { 33 | set_hexpand: true, 34 | set_opacity: 0.0, 35 | }, 36 | gtk::Image { 37 | set_icon_name: if self.modified { Some("system-run-symbolic") } else { Some("object-select-symbolic") }, 38 | set_visible: self.configured || self.modified, 39 | }, 40 | }, 41 | set_title: &self.value.join("."), 42 | } 43 | } 44 | 45 | fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 46 | value 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/searchpage.rs: -------------------------------------------------------------------------------- 1 | use super::{searchfactory::SearchOption, window::*}; 2 | use crate::parse::config::opconfigured; 3 | use adw::prelude::*; 4 | use relm4::{factory::*, *}; 5 | use std::{cmp::Ordering, collections::HashMap}; 6 | 7 | #[derive(Debug)] 8 | pub enum SearchPageMsg { 9 | Search(String, HashMap), 10 | OpenOption(Vec, Option>), 11 | LoadOptions(Vec<(String, bool, String)>), 12 | SetModifiedOnly(bool, bool), 13 | } 14 | 15 | pub struct SearchPageModel { 16 | pub options: Vec<(String, bool, String)>, 17 | pub oplst: FactoryVecDeque, 18 | query: String, 19 | modifiedonly: bool, 20 | } 21 | 22 | #[relm4::component(pub)] 23 | impl SimpleComponent for SearchPageModel { 24 | type Init = (); 25 | type Input = SearchPageMsg; 26 | type Output = AppMsg; 27 | type Widgets = SearchPageWidgets; 28 | 29 | view! { 30 | view = gtk::Stack { 31 | set_transition_type: gtk::StackTransitionType::Crossfade, 32 | #[name(options)] 33 | adw::PreferencesPage { 34 | set_title: "Attributes", 35 | add = &adw::PreferencesGroup { 36 | set_title: "Options", 37 | #[local_ref] 38 | add = oplstbox -> gtk::ListBox { 39 | add_css_class: "boxed-list", 40 | set_selection_mode: gtk::SelectionMode::None, 41 | connect_row_activated[sender] => move |_, y| { 42 | if let Ok(l) = y.clone().downcast::() { 43 | let text = l.title().to_string(); 44 | let v = text.split('.').map(|x| x.to_string()).collect::>(); 45 | sender.input(SearchPageMsg::OpenOption(v, None)); 46 | } 47 | }, 48 | }, 49 | } 50 | }, 51 | #[name(empty)] 52 | gtk::Box { 53 | set_orientation: gtk::Orientation::Vertical, 54 | set_valign: gtk::Align::Center, 55 | adw::StatusPage { 56 | set_icon_name: Some("edit-find-symbolic"), 57 | set_title: "No options found!", 58 | set_description: Some("Try a different search"), 59 | }, 60 | } 61 | } 62 | } 63 | 64 | fn pre_view() { 65 | if model.oplst.is_empty() { 66 | view.set_visible_child(empty); 67 | } else { 68 | view.set_visible_child(options); 69 | } 70 | } 71 | 72 | fn init( 73 | _value: Self::Init, 74 | root: &Self::Root, 75 | sender: ComponentSender, 76 | ) -> ComponentParts { 77 | let model = SearchPageModel { 78 | options: vec![], 79 | oplst: FactoryVecDeque::new(gtk::ListBox::new(), sender.input_sender()), 80 | query: String::default(), 81 | modifiedonly: false, 82 | }; 83 | let oplstbox = model.oplst.widget(); 84 | 85 | let widgets = view_output!(); 86 | 87 | ComponentParts { model, widgets } 88 | } 89 | 90 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 91 | let mut oplst_guard = self.oplst.guard(); 92 | match msg { 93 | SearchPageMsg::Search(query, editedopts) => { 94 | self.query = query.to_string(); 95 | oplst_guard.clear(); 96 | let q = query.split_whitespace(); 97 | let mut sortedoptions = self.options.clone(); 98 | if q.clone().any(|x| x.len() > 2) { 99 | sortedoptions = sortedoptions 100 | .iter() 101 | .filter(|x| { 102 | for part in q.clone() { 103 | if x.0.to_lowercase().contains(&part.to_lowercase()) { 104 | return true; 105 | } 106 | } 107 | false 108 | }) 109 | .map(|x| x.to_owned()) 110 | .collect::>(); 111 | sortedoptions.sort_by(|a, b| { 112 | let mut acount = 0; 113 | let mut bcount = 0; 114 | for part in q.clone() { 115 | acount += a.0.to_lowercase().matches(&part.to_lowercase()).count(); 116 | bcount += b.0.to_lowercase().matches(&part.to_lowercase()).count(); 117 | } 118 | match acount.cmp(&bcount) { 119 | Ordering::Less => Ordering::Less, 120 | Ordering::Greater => Ordering::Greater, 121 | Ordering::Equal => a.0.len().cmp(&b.0.len()), 122 | } 123 | }); 124 | } else { 125 | sortedoptions.sort_by(|a, b| a.0.len().cmp(&b.0.len())); 126 | } 127 | for opt in sortedoptions { 128 | if q.clone().all(|part| { 129 | opt.0.to_lowercase().contains(&part.to_lowercase()) 130 | || if q.clone().any(|x| x.len() > 2) { 131 | opt.2.to_lowercase().contains(&part.to_lowercase()) 132 | } else { 133 | false 134 | } 135 | }) { 136 | let configured = opt.1; 137 | let modified = opconfigured(&editedopts, &[], opt.0.clone()); 138 | if self.modifiedonly && !(configured || modified) { 139 | continue; 140 | } 141 | oplst_guard.push_back(SearchOption { 142 | value: opt 143 | .0 144 | .split('.') 145 | .map(|s| s.to_string()) 146 | .collect::>(), 147 | configured, 148 | modified, 149 | }); 150 | } 151 | if oplst_guard.len() >= 1000 { 152 | break; 153 | } 154 | } 155 | } 156 | SearchPageMsg::OpenOption(opt, refpos) => { 157 | if opt.contains(&String::from("*")) || opt.contains(&String::from("")) { 158 | let _ = sender.output(AppMsg::ShowSearchPageEntry(opt)); 159 | } else { 160 | let _ = sender.output(AppMsg::OpenOption( 161 | opt.clone(), 162 | if let Some(x) = refpos { x } else { opt }, 163 | )); 164 | } 165 | } 166 | SearchPageMsg::LoadOptions(options) => { 167 | self.options = options; 168 | } 169 | SearchPageMsg::SetModifiedOnly(modified, search) => { 170 | self.modifiedonly = modified; 171 | if search { 172 | let _ = sender.output(AppMsg::ShowSearchPage(self.query.clone())); 173 | } 174 | } 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/ui/treefactory.rs: -------------------------------------------------------------------------------- 1 | use super::window::*; 2 | use adw::prelude::*; 3 | use relm4::{factory::*, *}; 4 | 5 | #[derive(Default, Debug, PartialEq, Eq, Clone)] 6 | pub struct AttrPos { 7 | pub value: Vec, 8 | pub refvalue: Vec, 9 | pub configured: bool, 10 | pub modified: bool, 11 | pub replacefor: Option, 12 | } 13 | 14 | #[relm4::factory(pub)] 15 | impl FactoryComponent for AttrPos { 16 | type Init = AttrPos; 17 | type Input = AppMsg; 18 | type Output = AppMsg; 19 | type Widgets = AttrWidgets; 20 | type ParentWidget = gtk::ListBox; 21 | type ParentInput = AppMsg; 22 | type CommandOutput = (); 23 | 24 | view! { 25 | adw::PreferencesRow { 26 | #[wrap(Some)] 27 | set_child = >k::Box { 28 | set_orientation: gtk::Orientation::Horizontal, 29 | set_spacing: 6, 30 | set_margin_all: 15, 31 | gtk::Label { 32 | set_text: &{ 33 | if self.replacefor == Some(String::from("*")) { 34 | format!("[{}]", self.value.last().unwrap_or(&String::new())) 35 | } else { 36 | self.value.last().unwrap_or(&String::new()).to_string() 37 | } 38 | }, 39 | set_use_markup: true, 40 | }, 41 | gtk::Separator { 42 | set_hexpand: true, 43 | set_opacity: 0.0, 44 | }, 45 | gtk::Image { 46 | set_icon_name: if self.modified { Some("system-run-symbolic") } else { Some("object-select-symbolic") }, 47 | set_visible: self.configured || self.modified, 48 | }, 49 | }, 50 | set_title: &self.value.join("."), 51 | } 52 | } 53 | 54 | fn init_model(parent: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 55 | Self { 56 | value: parent.value, 57 | refvalue: parent.refvalue, 58 | configured: parent.configured, 59 | modified: parent.modified, 60 | replacefor: parent.replacefor, 61 | } 62 | } 63 | } 64 | 65 | #[derive(Default, Debug, PartialEq, Eq)] 66 | pub struct OptPos { 67 | pub value: Vec, 68 | pub refvalue: Vec, 69 | pub configured: bool, 70 | pub modified: bool, 71 | } 72 | 73 | #[relm4::factory(pub)] 74 | impl FactoryComponent for OptPos { 75 | type Init = OptPos; 76 | type Input = AppMsg; 77 | type Output = AppMsg; 78 | type Widgets = OptWidgets; 79 | type ParentWidget = gtk::ListBox; 80 | type ParentInput = AppMsg; 81 | type CommandOutput = (); 82 | 83 | view! { 84 | adw::PreferencesRow { 85 | #[wrap(Some)] 86 | set_child = >k::Box { 87 | set_orientation: gtk::Orientation::Horizontal, 88 | set_spacing: 6, 89 | set_margin_all: 15, 90 | gtk::Label { 91 | set_text: &{ 92 | self.value.last().unwrap_or(&String::new()).to_string() 93 | }, 94 | }, 95 | gtk::Separator { 96 | set_hexpand: true, 97 | set_opacity: 0.0, 98 | }, 99 | gtk::Image { 100 | set_icon_name: if self.modified { Some("system-run-symbolic") } else { Some("object-select-symbolic") }, 101 | set_visible: self.configured || self.modified, 102 | }, 103 | }, 104 | set_title: &self.value.join("."), 105 | } 106 | } 107 | 108 | fn init_model(parent: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 109 | Self { 110 | value: parent.value, 111 | refvalue: parent.refvalue, 112 | configured: parent.configured, 113 | modified: parent.modified, 114 | } 115 | } 116 | } 117 | 118 | #[derive(Default, Debug, PartialEq, Eq)] 119 | pub struct AttrBtn { 120 | pub value: Vec, 121 | pub refvalue: Vec, 122 | pub opt: bool, 123 | } 124 | 125 | #[derive(Debug)] 126 | pub enum AttrBtnMsg { 127 | OpenOption(Vec, Vec), 128 | MoveTo(Vec, Vec), 129 | } 130 | 131 | #[relm4::factory(pub)] 132 | impl FactoryComponent for AttrBtn { 133 | type Init = AttrBtn; 134 | type Input = (); 135 | type Output = AttrBtnMsg; 136 | type Widgets = AttrBtnWidgets; 137 | type ParentWidget = gtk::Box; 138 | type ParentInput = AppMsg; 139 | type CommandOutput = (); 140 | 141 | view! { 142 | #[name(button)] 143 | gtk::Button { 144 | set_label: self.value.last().unwrap_or(&String::new()), 145 | connect_clicked[sender, value = self.value.clone(), refvalue = self.refvalue.clone(), opt = self.opt] => move |_| { 146 | if opt { 147 | sender.output(AttrBtnMsg::OpenOption(value.to_vec(), refvalue.to_vec())); 148 | } else { 149 | sender.output(AttrBtnMsg::MoveTo(value.to_vec(), refvalue.to_vec())); 150 | } 151 | } 152 | } 153 | } 154 | 155 | fn init_model(parent: Self::Init, _index: &DynamicIndex, _sender: FactorySender) -> Self { 156 | Self { 157 | value: parent.value, 158 | refvalue: parent.refvalue, 159 | opt: parent.opt, 160 | } 161 | } 162 | 163 | fn output_to_parent_input(output: Self::Output) -> Option { 164 | Some(match output { 165 | AttrBtnMsg::OpenOption(v, r) => AppMsg::OpenOption(v, r), 166 | AttrBtnMsg::MoveTo(v, r) => AppMsg::MoveTo(v, r), 167 | }) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/ui/welcome.rs: -------------------------------------------------------------------------------- 1 | use super::window::AppMsg; 2 | use adw::prelude::*; 3 | use log::info; 4 | use nix_data::config::configfile::NixDataConfig; 5 | use relm4::*; 6 | use relm4_components::open_dialog::*; 7 | use std::path::{Path, PathBuf}; 8 | 9 | pub struct WelcomeModel { 10 | hidden: bool, 11 | confpath: Option, 12 | flakepath: Option, 13 | conf_dialog: Controller, 14 | flake_dialog: Controller, 15 | } 16 | 17 | #[derive(Debug)] 18 | pub enum WelcomeMsg { 19 | Show, 20 | Close, 21 | UpdateConfPath(PathBuf), 22 | UpdateFlakePath(PathBuf), 23 | ClearFlakePath, 24 | OpenConf, 25 | OpenFlake, 26 | Ignore, 27 | } 28 | 29 | #[relm4::component(pub)] 30 | impl SimpleComponent for WelcomeModel { 31 | type Init = gtk::Window; 32 | type Input = WelcomeMsg; 33 | type Output = AppMsg; 34 | type Widgets = WelcomeWidgets; 35 | 36 | view! { 37 | window = adw::Window { 38 | set_transient_for: Some(&parent_window), 39 | set_modal: true, 40 | #[watch] 41 | set_visible: !model.hidden, 42 | gtk::Box { 43 | set_orientation: gtk::Orientation::Vertical, 44 | gtk::Box { 45 | set_valign: gtk::Align::Center, 46 | set_vexpand: true, 47 | set_orientation: gtk::Orientation::Vertical, 48 | set_spacing: 20, 49 | set_margin_all: 20, 50 | gtk::Box { 51 | set_orientation: gtk::Orientation::Vertical, 52 | set_spacing: 10, 53 | gtk::Label { 54 | add_css_class: "title-1", 55 | set_text: "Welcome the NixOS Configuration Editor!", 56 | set_justify: gtk::Justification::Center, 57 | }, 58 | gtk::Label { 59 | add_css_class: "dim-label", 60 | set_text: "If your configuration file is not in the default location, you can change it here.", 61 | }, 62 | }, 63 | gtk::ListBox { 64 | add_css_class: "boxed-list", 65 | set_halign: gtk::Align::Fill, 66 | set_selection_mode: gtk::SelectionMode::None, 67 | adw::ActionRow { 68 | set_title: "Configuration file", 69 | add_suffix = >k::Button { 70 | set_halign: gtk::Align::Center, 71 | set_valign: gtk::Align::Center, 72 | gtk::Box { 73 | set_orientation: gtk::Orientation::Horizontal, 74 | set_spacing: 5, 75 | gtk::Image { 76 | set_icon_name: Some("document-open-symbolic"), 77 | }, 78 | gtk::Label { 79 | #[watch] 80 | set_label: { 81 | if let Some(path) = &model.confpath { 82 | let x = path.file_name().unwrap_or_default().to_str().unwrap_or_default(); 83 | if x.is_empty() { 84 | "(None)" 85 | } else { 86 | x 87 | } 88 | } else { 89 | "(None)" 90 | } 91 | } 92 | } 93 | }, 94 | connect_clicked[sender] => move |_| { 95 | sender.input(WelcomeMsg::OpenConf); 96 | } 97 | }, 98 | }, 99 | }, 100 | gtk::ListBox { 101 | add_css_class: "boxed-list", 102 | set_halign: gtk::Align::Fill, 103 | set_selection_mode: gtk::SelectionMode::None, 104 | adw::ActionRow { 105 | set_title: "Flake file", 106 | set_subtitle: "If you are using flakes, you can specify the path to your flake.nix file here.", 107 | add_suffix = >k::Button { 108 | set_halign: gtk::Align::Center, 109 | set_valign: gtk::Align::Center, 110 | gtk::Box { 111 | set_orientation: gtk::Orientation::Horizontal, 112 | set_spacing: 5, 113 | gtk::Image { 114 | set_icon_name: Some("document-open-symbolic"), 115 | }, 116 | gtk::Label { 117 | #[watch] 118 | set_label: { 119 | if let Some(path) = &model.flakepath { 120 | let x = path.file_name().unwrap_or_default().to_str().unwrap_or_default(); 121 | if x.is_empty() { 122 | "(None)" 123 | } else { 124 | x 125 | } 126 | } else { 127 | "(None)" 128 | } 129 | } 130 | } 131 | }, 132 | connect_clicked[sender] => move |_| { 133 | sender.input(WelcomeMsg::OpenFlake); 134 | } 135 | }, 136 | add_suffix = >k::Button { 137 | set_halign: gtk::Align::Center, 138 | set_valign: gtk::Align::Center, 139 | set_icon_name: "user-trash-symbolic", 140 | connect_clicked[sender] => move |_| { 141 | sender.input(WelcomeMsg::ClearFlakePath); 142 | } 143 | } 144 | }, 145 | }, 146 | #[name(btn)] 147 | gtk::Button { 148 | #[watch] 149 | set_sensitive: model.confpath.is_some(), 150 | add_css_class: "pill", 151 | add_css_class: "suggested-action", 152 | set_label: "Continue", 153 | set_hexpand: false, 154 | set_halign: gtk::Align::Center, 155 | connect_clicked[sender] => move |_| { 156 | sender.input(WelcomeMsg::Close); 157 | }, 158 | } 159 | } 160 | } 161 | } 162 | } 163 | 164 | fn init( 165 | parent_window: Self::Init, 166 | root: &Self::Root, 167 | sender: ComponentSender, 168 | ) -> ComponentParts { 169 | let conf_dialog = OpenDialog::builder() 170 | .transient_for_native(root) 171 | .launch(OpenDialogSettings::default()) 172 | .forward(sender.input_sender(), |response| match response { 173 | OpenDialogResponse::Accept(path) => WelcomeMsg::UpdateConfPath(path), 174 | OpenDialogResponse::Cancel => WelcomeMsg::Ignore, 175 | }); 176 | 177 | let flake_dialog = OpenDialog::builder() 178 | .transient_for_native(root) 179 | .launch(OpenDialogSettings::default()) 180 | .forward(sender.input_sender(), |response| match response { 181 | OpenDialogResponse::Accept(path) => WelcomeMsg::UpdateFlakePath(path), 182 | OpenDialogResponse::Cancel => WelcomeMsg::Ignore, 183 | }); 184 | 185 | let model = WelcomeModel { 186 | hidden: true, 187 | confpath: if Path::new("/etc/nixos/configuration.nix").exists() { 188 | Some(PathBuf::from("/etc/nixos/configuration.nix")) 189 | } else { 190 | None 191 | }, // parent_window.configpath.to_string(), 192 | flakepath: None, 193 | conf_dialog, 194 | flake_dialog, 195 | }; 196 | 197 | let widgets = view_output!(); 198 | 199 | widgets.btn.grab_focus(); 200 | 201 | ComponentParts { model, widgets } 202 | } 203 | 204 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 205 | match msg { 206 | WelcomeMsg::Show => { 207 | self.hidden = false; 208 | } 209 | WelcomeMsg::Close => { 210 | if let Some(confpath) = &self.confpath { 211 | let _ = sender.output(AppMsg::SetConfig(NixDataConfig { 212 | systemconfig: Some(confpath.to_string_lossy().to_string()), 213 | flake: self 214 | .flakepath 215 | .as_ref() 216 | .map(|x| x.to_string_lossy().to_string()), 217 | flakearg: None, 218 | generations: None, 219 | })); 220 | self.hidden = true; 221 | } 222 | } 223 | WelcomeMsg::UpdateConfPath(s) => { 224 | info!("Set configuration path to {}", s.to_string_lossy()); 225 | self.confpath = Some(s); 226 | } 227 | WelcomeMsg::UpdateFlakePath(s) => { 228 | info!("Set flake path to {}", s.to_string_lossy()); 229 | self.flakepath = Some(s); 230 | } 231 | WelcomeMsg::ClearFlakePath => { 232 | info!("Clear flake path"); 233 | self.flakepath = None; 234 | } 235 | WelcomeMsg::OpenConf => self.conf_dialog.emit(OpenDialogMsg::Open), 236 | WelcomeMsg::OpenFlake => self.flake_dialog.emit(OpenDialogMsg::Open), 237 | WelcomeMsg::Ignore => {} 238 | } 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/ui/windowloading.rs: -------------------------------------------------------------------------------- 1 | use super::window::{AppMsg, LoadValues}; 2 | use crate::parse::config::parseconfig; 3 | use crate::parse::options::read; 4 | use crate::parse::preferences::editconfig; 5 | use log::*; 6 | use nix_data::config::configfile::NixDataConfig; 7 | use relm4::adw::prelude::*; 8 | use relm4::*; 9 | use std::path::Path; 10 | 11 | pub struct WindowAsyncHandler; 12 | 13 | #[derive(Debug)] 14 | pub enum WindowAsyncHandlerMsg { 15 | RunWindow(String), 16 | GetConfigPath(Option), 17 | SetConfig(NixDataConfig), 18 | } 19 | 20 | impl Worker for WindowAsyncHandler { 21 | type Init = (); 22 | type Input = WindowAsyncHandlerMsg; 23 | type Output = AppMsg; 24 | 25 | fn init(_params: Self::Init, _sender: relm4::ComponentSender) -> Self { 26 | Self 27 | } 28 | 29 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 30 | match msg { 31 | WindowAsyncHandlerMsg::RunWindow(path) => { 32 | let optionfile = match nix_data::cache::nixos::nixosoptions() { 33 | Ok(x) => x, 34 | Err(e) => { 35 | error!("{}", e); 36 | let _ = sender.output(AppMsg::LoadError( 37 | String::from("Could not load cache"), 38 | String::from( 39 | "Try connecting to the internet or launching the application again", 40 | ), 41 | )); 42 | return; 43 | } 44 | }; 45 | 46 | let (data, tree) = match read(&optionfile) { 47 | Ok(x) => x, 48 | Err(e) => { 49 | error!("{}", e); 50 | let _ = sender.output(AppMsg::LoadError( 51 | String::from("Could not load options"), 52 | String::from("Try launching the application again"), 53 | )); 54 | return; 55 | } 56 | }; 57 | 58 | let conf = match parseconfig(&path) { 59 | Ok(x) => x, 60 | Err(e) => { 61 | error!("{}", e); 62 | let _ = sender.output(AppMsg::LoadError( 63 | String::from("Error loading configuration file"), 64 | format!("{} may be an invalid configuration file", path), 65 | )); 66 | return; 67 | } 68 | }; 69 | let _ = sender.output(AppMsg::InitialLoad(LoadValues { data, tree, conf })); 70 | } 71 | WindowAsyncHandlerMsg::GetConfigPath(cfg) => { 72 | warn!("CFG: {:?}", cfg); 73 | if let Some(config) = cfg { 74 | if let Some(systemconfig) = &config.systemconfig { 75 | if Path::new(&systemconfig).exists() { 76 | if let Some(flakepath) = &config.flake { 77 | if !Path::new(flakepath).exists() { 78 | let _ = sender.output(AppMsg::Welcome); 79 | return; 80 | } 81 | } 82 | let _ = sender.output(AppMsg::SetConfig(config)); 83 | } else { 84 | let _ = sender.output(AppMsg::Welcome); 85 | } 86 | } else { 87 | let _ = sender.output(AppMsg::Welcome); 88 | } 89 | } else { 90 | let _ = sender.output(AppMsg::Welcome); 91 | } 92 | } 93 | WindowAsyncHandlerMsg::SetConfig(cfg) => { 94 | let _ = match editconfig(cfg) { 95 | Ok(_) => sender.output(AppMsg::TryLoad), 96 | Err(_) => sender.output(AppMsg::LoadError( 97 | String::from("Error loading configuration file"), 98 | String::from("Try launching the application again"), 99 | )), 100 | }; 101 | } 102 | } 103 | } 104 | } 105 | 106 | pub struct LoadErrorModel { 107 | hidden: bool, 108 | msg: String, 109 | msg2: String, 110 | } 111 | 112 | #[derive(Debug)] 113 | pub enum LoadErrorMsg { 114 | Show(String, String), 115 | Retry, 116 | Close, 117 | Preferences, 118 | } 119 | 120 | #[relm4::component(pub)] 121 | impl SimpleComponent for LoadErrorModel { 122 | type Init = gtk::Window; 123 | type Input = LoadErrorMsg; 124 | type Output = AppMsg; 125 | type Widgets = LoadErrorWidgets; 126 | 127 | view! { 128 | dialog = gtk::MessageDialog { 129 | set_transient_for: Some(&parent_window), 130 | set_modal: true, 131 | #[watch] 132 | set_visible: !model.hidden, 133 | #[watch] 134 | set_text: Some(&model.msg), 135 | #[watch] 136 | set_secondary_text: Some(&model.msg2), 137 | set_use_markup: true, 138 | set_secondary_use_markup: true, 139 | add_button: ("Retry", gtk::ResponseType::Accept), 140 | add_button: ("Preferences", gtk::ResponseType::Help), 141 | add_button: ("Quit", gtk::ResponseType::Close), 142 | connect_response[sender] => move |_, resp| { 143 | sender.input(match resp { 144 | gtk::ResponseType::Accept => LoadErrorMsg::Retry, 145 | gtk::ResponseType::Close => LoadErrorMsg::Close, 146 | gtk::ResponseType::Help => LoadErrorMsg::Preferences, 147 | _ => unreachable!(), 148 | }); 149 | }, 150 | } 151 | } 152 | 153 | fn init( 154 | parent_window: Self::Init, 155 | root: &Self::Root, 156 | sender: ComponentSender, 157 | ) -> ComponentParts { 158 | let model = LoadErrorModel { 159 | hidden: true, 160 | msg: String::default(), 161 | msg2: String::default(), 162 | }; 163 | let widgets = view_output!(); 164 | let accept_widget = widgets 165 | .dialog 166 | .widget_for_response(gtk::ResponseType::Accept) 167 | .expect("No button for accept response set"); 168 | accept_widget.add_css_class("warning"); 169 | let pref_widget = widgets 170 | .dialog 171 | .widget_for_response(gtk::ResponseType::Help) 172 | .expect("No button for help response set"); 173 | pref_widget.add_css_class("suggested-action"); 174 | ComponentParts { model, widgets } 175 | } 176 | 177 | fn update(&mut self, msg: Self::Input, sender: ComponentSender) { 178 | match msg { 179 | LoadErrorMsg::Show(s, s2) => { 180 | self.hidden = false; 181 | self.msg = s; 182 | self.msg2 = s2; 183 | } 184 | LoadErrorMsg::Retry => { 185 | self.hidden = true; 186 | let _ = sender.output(AppMsg::TryLoad); 187 | } 188 | LoadErrorMsg::Close => { 189 | let _ = sender.output(AppMsg::Close); 190 | } 191 | LoadErrorMsg::Preferences => { 192 | let _ = sender.output(AppMsg::ShowPrefMenuErr); 193 | self.hidden = true; 194 | } 195 | } 196 | } 197 | } 198 | --------------------------------------------------------------------------------