├── .config ├── lychee.toml └── nextest.toml ├── .github └── workflows │ ├── book.yml │ ├── build.yml │ ├── checksum.yml │ ├── gh-page-unstable.yml │ ├── gh-page.yml │ ├── release.yml │ └── spell-check.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── VERSION ├── assets ├── animation.gif ├── flatpak │ └── org.squidowl.halloy.json ├── fontello │ └── config.json ├── linux │ ├── icons │ │ └── hicolor │ │ │ ├── 128x128 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 16x16 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 24x24 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 256x256 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 32x32 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 48x48 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 512x512 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ ├── 64x64 │ │ │ └── apps │ │ │ │ └── org.squidowl.halloy.png │ │ │ └── 96x96 │ │ │ └── apps │ │ │ └── org.squidowl.halloy.png │ ├── org.squidowl.halloy.appdata.xml │ └── org.squidowl.halloy.desktop ├── logo.png ├── macos │ └── Halloy.app │ │ └── Contents │ │ ├── Info.plist │ │ └── Resources │ │ └── halloy.icns ├── screenshot.png ├── themes │ └── ferra.toml └── windows │ ├── halloy.ico │ ├── halloy.manifest │ └── halloy.rc ├── book ├── .gitignore ├── CNAME ├── book.toml ├── src │ ├── README.md │ ├── SUMMARY.md │ ├── commands.md │ ├── configuration │ │ ├── README.md │ │ ├── actions.md │ │ ├── buffer.md │ │ ├── ctcp.md │ │ ├── file_transfer.md │ │ ├── font.md │ │ ├── highlights.md │ │ ├── keyboard.md │ │ ├── notifications.md │ │ ├── pane.md │ │ ├── preview.md │ │ ├── proxy.md │ │ ├── scale-factor.md │ │ ├── servers.md │ │ ├── sidebar.md │ │ ├── themes │ │ │ ├── README.md │ │ │ ├── base16.md │ │ │ └── community.md │ │ └── tooltips.md │ ├── get-in-touch.md │ ├── guides │ │ ├── connect-with-soju.md │ │ ├── connect-with-znc.md │ │ ├── getting-started.md │ │ ├── migrating-from-yaml.md │ │ ├── monitor-users.md │ │ ├── multiple-servers.md │ │ ├── password-file.md │ │ ├── portable-mode.md │ │ └── text-formatting.md │ ├── images │ │ ├── animation.gif │ │ └── banner-logo.png │ ├── installation.md │ └── url-schemes.md └── theme │ └── favicon.png ├── build.rs ├── config.toml ├── data ├── Cargo.toml ├── build.rs └── src │ ├── appearance.rs │ ├── appearance │ └── theme.rs │ ├── audio.rs │ ├── buffer.rs │ ├── channel.rs │ ├── client.rs │ ├── command.rs │ ├── compression.rs │ ├── config.rs │ ├── config │ ├── actions.rs │ ├── buffer.rs │ ├── buffer │ │ ├── away.rs │ │ └── channel.rs │ ├── ctcp.rs │ ├── file_transfer.rs │ ├── highlights.rs │ ├── keys.rs │ ├── notification.rs │ ├── pane.rs │ ├── preview.rs │ ├── proxy.rs │ ├── server.rs │ └── sidebar.rs │ ├── ctcp.rs │ ├── dashboard.rs │ ├── dcc.rs │ ├── environment.rs │ ├── file_transfer.rs │ ├── file_transfer │ ├── manager.rs │ └── task.rs │ ├── history.rs │ ├── history │ ├── manager.rs │ └── metadata.rs │ ├── input.rs │ ├── isupport.rs │ ├── lib.rs │ ├── log.rs │ ├── message.rs │ ├── message │ ├── broadcast.rs │ ├── formatting.rs │ ├── formatting │ │ └── encode.rs │ └── source.rs │ ├── mode.rs │ ├── notification.rs │ ├── pane.rs │ ├── preview.rs │ ├── preview │ ├── cache.rs │ ├── card.rs │ └── image.rs │ ├── serde.rs │ ├── server.rs │ ├── shortcut.rs │ ├── stream.rs │ ├── target.rs │ ├── time.rs │ ├── url.rs │ ├── user.rs │ ├── version.rs │ ├── window.rs │ └── window │ ├── position.rs │ └── size.rs ├── fonts ├── halloy-icons.ttf ├── iosevka-term-bold.ttf ├── iosevka-term-italic.ttf └── iosevka-term-regular.ttf ├── ipc ├── Cargo.toml └── src │ ├── client.rs │ ├── lib.rs │ └── server.rs ├── irc ├── Cargo.toml ├── proto │ ├── Cargo.toml │ └── src │ │ ├── command.rs │ │ ├── format.rs │ │ ├── lib.rs │ │ └── parse.rs └── src │ ├── codec.rs │ ├── connection.rs │ ├── connection │ ├── proxy.rs │ └── tls.rs │ └── lib.rs ├── rustfmt.toml ├── scripts ├── build-macos.sh ├── build-windows-installer.sh ├── build-windows.sh ├── flatpak.sh ├── format.sh ├── generate-icons.sh ├── package-linux.sh ├── package-macos.sh └── sign-macos.sh ├── sounds ├── bonk.ogg ├── dong.ogg ├── peck.ogg ├── ring.ogg ├── sing.ogg ├── squeak.ogg └── whistle.ogg ├── src ├── appearance.rs ├── appearance │ ├── theme.rs │ └── theme │ │ ├── button.rs │ │ ├── checkbox.rs │ │ ├── container.rs │ │ ├── context_menu.rs │ │ ├── image.rs │ │ ├── menu.rs │ │ ├── pane_grid.rs │ │ ├── progress_bar.rs │ │ ├── rule.rs │ │ ├── scrollable.rs │ │ ├── selectable_text.rs │ │ ├── text.rs │ │ └── text_input.rs ├── audio.rs ├── buffer.rs ├── buffer │ ├── channel.rs │ ├── channel │ │ └── topic.rs │ ├── empty.rs │ ├── file_transfers.rs │ ├── highlights.rs │ ├── input_view.rs │ ├── input_view │ │ ├── completion.rs │ │ └── format_tooltip.txt │ ├── logs.rs │ ├── query.rs │ ├── scroll_view.rs │ ├── server.rs │ └── user_context.rs ├── event.rs ├── font.rs ├── icon.rs ├── logger.rs ├── main.rs ├── modal.rs ├── modal │ ├── connect_to_server.rs │ ├── image_preview.rs │ ├── prompt_before_open_url.rs │ └── reload_configuration_error.rs ├── notification.rs ├── notification │ └── toast.rs ├── screen.rs ├── screen │ ├── dashboard.rs │ ├── dashboard │ │ ├── command_bar.rs │ │ ├── pane.rs │ │ ├── sidebar.rs │ │ └── theme_editor.rs │ ├── help.rs │ ├── migration.rs │ └── welcome.rs ├── stream.rs ├── url.rs ├── widget.rs ├── widget │ ├── anchored_overlay.rs │ ├── collection.rs │ ├── color_picker.rs │ ├── combo_box.rs │ ├── context_menu.rs │ ├── decorate.rs │ ├── double_click.rs │ ├── double_pass.rs │ ├── key_press.rs │ ├── message_content.rs │ ├── modal.rs │ ├── notify_visibility.rs │ ├── selectable_rich_text.rs │ ├── selectable_text.rs │ ├── selectable_text │ │ └── selection.rs │ ├── shortcut.rs │ └── tooltip.rs └── window.rs └── wix ├── banner.png ├── dialog.png ├── license.rtf └── main.wxs /.config/lychee.toml: -------------------------------------------------------------------------------- 1 | verbose = "info" 2 | no_progress = true 3 | 4 | include_fragments = true 5 | 6 | # Exclude all non-Halloy GitHub links to avoid throttling 7 | exclude = [ '^https://halloy\.squidowl\.org/.*', '^https://github.com/.*', '^irc://.*', '^ircs://.*' ] 8 | include = [ '^https://github.com/squidowl/halloy/.*' ] 9 | -------------------------------------------------------------------------------- /.config/nextest.toml: -------------------------------------------------------------------------------- 1 | [profile.ci] 2 | # Do not cancel the test run on the first failure. 3 | fail-fast = false 4 | -------------------------------------------------------------------------------- /.github/workflows/book.yml: -------------------------------------------------------------------------------- 1 | name: Book 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - 'book/**.md' 7 | - 'book/book.toml' 8 | push: 9 | branches: 10 | - main 11 | paths: 12 | - 'book/**.md' 13 | - 'book/book.toml' 14 | merge_group: 15 | paths: 16 | - 'book/**.md' 17 | - 'book/book.toml' 18 | 19 | jobs: 20 | build-linkcheck: 21 | name: Build & Link Check 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Check links 27 | id: lychee 28 | uses: lycheeverse/lychee-action@v2 29 | with: 30 | args: --config ./.config/lychee.toml './**/*.md' 31 | fail: true 32 | 33 | - name: Setup mdBook 34 | uses: peaceiris/actions-mdbook@v1 35 | with: 36 | mdbook-version: 'latest' 37 | 38 | - name: Build book 39 | run: mdbook build book 40 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - '**.rs' 7 | - '**/Cargo.toml' 8 | - 'Cargo.lock' 9 | push: 10 | branches: 11 | - main 12 | paths: 13 | - '**.rs' 14 | - '**/Cargo.toml' 15 | - 'Cargo.lock' 16 | merge_group: 17 | paths: 18 | - '**.rs' 19 | - '**/Cargo.toml' 20 | - 'Cargo.lock' 21 | 22 | env: 23 | CARGO_TERM_COLOR: always 24 | 25 | jobs: 26 | build-check-clippy-test: 27 | name: Build, Check, Clippy, & Test 28 | runs-on: ubuntu-latest 29 | env: 30 | RUSTFLAGS: "-C linker=clang -C link-arg=-fuse-ld=mold" 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Install dependencies 34 | run: | 35 | sudo apt update 36 | sudo apt install \ 37 | build-essential \ 38 | git \ 39 | pkg-config \ 40 | mold \ 41 | clang \ 42 | libdbus-1-dev \ 43 | libudev-dev \ 44 | libxkbcommon-dev \ 45 | libfontconfig1-dev \ 46 | libasound2-dev 47 | - uses: actions/cache@v4 48 | with: 49 | path: | 50 | ~/.cargo/bin/ 51 | ~/.cargo/registry/index/ 52 | ~/.cargo/registry/cache/ 53 | ~/.cargo/git/db/ 54 | target/ 55 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} 56 | 57 | - name: Check 58 | run: cargo check --profile ci 59 | 60 | - name: Clippy 61 | run: cargo clippy --profile ci --workspace --all-targets -- -D warnings 62 | 63 | - uses: taiki-e/install-action@v2 64 | with: 65 | tool: cargo-nextest 66 | - name: Test 67 | run: cargo nextest run --profile ci --workspace --all-targets 68 | -------------------------------------------------------------------------------- /.github/workflows/checksum.yml: -------------------------------------------------------------------------------- 1 | name: Checksum 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: "Specify tag to generate checksum" 8 | required: true 9 | 10 | jobs: 11 | deploy: 12 | name: Deploy 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Download Artifacts 16 | uses: robinraju/release-downloader@v1.10 17 | with: 18 | tag: ${{ github.event.inputs.tag }} 19 | fileName: h* 20 | 21 | - name: Generate checksum 22 | uses: jmgilman/actions-generate-checksum@v1 23 | with: 24 | patterns: h* 25 | method: sha256 26 | output: checksums.txt 27 | 28 | - name: Publish checksums 29 | uses: svenstaro/upload-release-action@v2 30 | with: 31 | repo_token: ${{ secrets.GITHUB_TOKEN }} 32 | file: checksums.txt 33 | tag: ${{ github.event.inputs.tag }} 34 | overwrite: true 35 | -------------------------------------------------------------------------------- /.github/workflows/gh-page-unstable.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages (Unstable) 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Check links 17 | id: lychee 18 | uses: lycheeverse/lychee-action@v2 19 | with: 20 | args: --config ./.config/lychee.toml './**/*.md' 21 | fail: true 22 | 23 | - name: Setup mdBook 24 | uses: peaceiris/actions-mdbook@v1 25 | with: 26 | mdbook-version: 'latest' 27 | 28 | - name: Build book 29 | run: mdbook build book 30 | 31 | - name: Publish book 32 | uses: peaceiris/actions-gh-pages@v3 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | publish_branch: gh-pages-unstable 36 | publish_dir: ./book/book 37 | -------------------------------------------------------------------------------- /.github/workflows/gh-page.yml: -------------------------------------------------------------------------------- 1 | name: Github Pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | jobs: 10 | deploy: 11 | name: Deploy 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Check links 17 | id: lychee 18 | uses: lycheeverse/lychee-action@v2 19 | with: 20 | args: --config ./.config/lychee.toml './**/*.md' 21 | fail: true 22 | 23 | - name: Setup mdBook 24 | uses: peaceiris/actions-mdbook@v1 25 | with: 26 | mdbook-version: 'latest' 27 | 28 | - name: Build book 29 | run: mdbook build book 30 | 31 | - name: Publish book 32 | uses: peaceiris/actions-gh-pages@v3 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | publish_dir: ./book/book 36 | -------------------------------------------------------------------------------- /.github/workflows/spell-check.yml: -------------------------------------------------------------------------------- 1 | name: Spell Check 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | merge_group: 9 | 10 | jobs: 11 | typos: 12 | name: Typos 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Check spelling 17 | uses: crate-ci/typos@v1.32.0 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | #Added by cargo 9 | /target 10 | 11 | # OS Specific 12 | .DS_Store 13 | 14 | # Environment variables 15 | .env 16 | 17 | # Editor config 18 | .idea/ 19 | .vscode/ 20 | .helix/ 21 | 22 | /assets/flatpak/generated-sources.json 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Halloy - IRC Client 2 | 3 | ![banner](https://github.com/squidowl/halloy/assets/2248455/57144563-02aa-40ed-a626-35d2a731a82a) 4 | ![halloy](./assets/animation.gif) 5 | 6 | Halloy is an open-source IRC client written in Rust, with the Iced GUI library. It aims to provide a simple and fast client for Mac, Windows, and Linux platforms. 7 | 8 | * Documentation for latest release: [https://halloy.chat](https://halloy.chat). 9 | * Documentation for main branch (when building from source): [https://unstable.halloy.chat](https://unstable.halloy.chat). 10 | 11 | Join **#halloy** on libera.chat if you have questions or looking for help. 12 | 13 | ## Installation 14 | 15 | [Installation documentation](https://halloy.chat/installation.html) 16 | 17 | 18 | Packaging status 19 | 20 | 21 | Halloy is also available from [Flathub](https://flathub.org/apps/org.squidowl.halloy) and [Snap Store](https://snapcraft.io/halloy). 22 | 23 | ## Features 24 | 25 | * IRCv3.2 capabilities 26 | * [account-notify](https://ircv3.net/specs/extensions/account-notify) 27 | * [away-notify](https://ircv3.net/specs/extensions/away-notify) 28 | * [batch](https://ircv3.net/specs/extensions/batch) 29 | * [cap-notify](https://ircv3.net/specs/extensions/capability-negotiation.html#cap-notify) 30 | * [chathistory](https://ircv3.net/specs/extensions/chathistory) 31 | * [chghost](https://ircv3.net/specs/extensions/chghost) 32 | * [echo-message](https://ircv3.net/specs/extensions/echo-message) 33 | * [extended-join](https://ircv3.net/specs/extensions/extended-join) 34 | * [invite-notify](https://ircv3.net/specs/extensions/invite-notify) 35 | * [labeled-response](https://ircv3.net/specs/extensions/labeled-response) 36 | * [message-tags](https://ircv3.net/specs/extensions/message-tags) 37 | * [Monitor](https://ircv3.net/specs/extensions/monitor) 38 | * [msgid](https://ircv3.net/specs/extensions/message-ids) 39 | * [multi-prefix](https://ircv3.net/specs/extensions/multi-prefix) 40 | * [read-marker](https://ircv3.net/specs/extensions/read-marker) 41 | * [sasl-3.1](https://ircv3.net/specs/extensions/sasl-3.1) 42 | * [server-time](https://ircv3.net/specs/extensions/server-time) 43 | * [setname](https://ircv3.net/specs/extensions/setname.html) 44 | * [Standard Replies](https://ircv3.net/specs/extensions/standard-replies) 45 | * [userhost-in-names](https://ircv3.net/specs/extensions/userhost-in-names) 46 | * [`UTF8ONLY`](https://ircv3.net/specs/extensions/utf8-only) 47 | * [`WHOX`](https://ircv3.net/specs/extensions/whox) 48 | * SASL support 49 | * DCC Send 50 | * Keyboard shortcuts 51 | * Auto-completion for nicknames, commands, and channels 52 | * Notifications support 53 | * Multiple channels at the same time across servers 54 | * Command bar for for quick actions 55 | * Custom themes 56 | * Portable mode 57 | 58 | ## Why? 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ## License 67 | 68 | Halloy is released under the GPL-3.0 License. For more details, see the [LICENSE](LICENSE) file. 69 | 70 | ## Contact 71 | 72 | For any questions, suggestions, or issues, please open an issue on the [GitHub repository](https://github.com/squidowl/halloy/issues). 73 | 74 | 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 2025.5 2 | -------------------------------------------------------------------------------- /assets/animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/animation.gif -------------------------------------------------------------------------------- /assets/flatpak/org.squidowl.halloy.json: -------------------------------------------------------------------------------- 1 | { 2 | "app-id": "org.squidowl.halloy", 3 | "runtime": "org.freedesktop.Platform", 4 | "runtime-version": "24.08", 5 | "sdk": "org.freedesktop.Sdk", 6 | "sdk-extensions" : [ 7 | "org.freedesktop.Sdk.Extension.rust-stable" 8 | ], 9 | "command": "halloy", 10 | "finish-args": [ 11 | "--device=dri", 12 | "--share=ipc", 13 | "--share=network", 14 | "--socket=fallback-x11", 15 | "--socket=pulseaudio", 16 | "--socket=wayland", 17 | "--talk-name=org.freedesktop.Notifications" 18 | ], 19 | "build-options": { 20 | "append-path" : "/usr/lib/sdk/rust-stable/bin" 21 | }, 22 | "modules": [ 23 | { 24 | "name": "halloy", 25 | "buildsystem": "simple", 26 | "build-options": { 27 | "env": { 28 | "CARGO_HOME": "/run/build/halloy/cargo" 29 | } 30 | }, 31 | "build-commands": [ 32 | "cargo --offline fetch --manifest-path Cargo.toml --verbose", 33 | "cargo --offline build --release --verbose", 34 | "mkdir -p /app/share/icons && cp -R ./assets/linux/icons/. /app/share/icons/", 35 | "install -Dm644 ./assets/linux/org.squidowl.halloy.appdata.xml /app/share/metainfo/org.squidowl.halloy.appdata.xml", 36 | "install -Dm644 ./assets/linux/org.squidowl.halloy.desktop /app/share/applications/org.squidowl.halloy.desktop", 37 | "install -Dm755 ./target/release/halloy -t /app/bin/" 38 | ], 39 | "sources": [ 40 | { 41 | "type": "dir", 42 | "path": "../.." 43 | }, 44 | "generated-sources.json" 45 | ] 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/128x128/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/128x128/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/16x16/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/16x16/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/24x24/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/24x24/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/256x256/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/256x256/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/32x32/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/32x32/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/48x48/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/48x48/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/512x512/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/512x512/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/64x64/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/64x64/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/icons/hicolor/96x96/apps/org.squidowl.halloy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/linux/icons/hicolor/96x96/apps/org.squidowl.halloy.png -------------------------------------------------------------------------------- /assets/linux/org.squidowl.halloy.appdata.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | moderate 5 | 6 | org.squidowl.halloy 7 | MIT 8 | GPL-3.0-or-later 9 | Halloy 10 | IRC client written in Rust 11 | 12 |

13 | Halloy is an open-source IRC client written in Rust, with the Iced GUI library. 14 | It aims to provide a simple and fast client for Mac, Windows, and Linux platforms. 15 |

16 |
17 | org.squidowl.halloy.desktop 18 | https://github.com/squidowl/halloy 19 | https://github.com/squidowl/halloy/issues 20 | 21 | 22 | halloy logo 23 | https://raw.githubusercontent.com/squidowl/halloy/7aec18d29ffb1d3605131667c6e11d8cef6ada7b/assets/screenshot.png 24 | 25 | 26 | The Squidowl Development Team 27 | 28 | 30 | 31 |
32 | -------------------------------------------------------------------------------- /assets/linux/org.squidowl.halloy.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=Halloy 3 | Comment=IRC client written in Rust 4 | Type=Application 5 | Keywords=IRC;IM;Chat; 6 | Categories=Network;IRCClient; 7 | Exec=halloy %U 8 | Icon=org.squidowl.halloy 9 | StartupWMClass=org.squidowl.halloy 10 | MimeType=x-scheme-handler/irc;x-scheme-handler/ircs;x-scheme-handler/halloy; 11 | Terminal=false 12 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/logo.png -------------------------------------------------------------------------------- /assets/macos/Halloy.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | halloy 9 | CFBundleIdentifier 10 | org.squidowl.halloy 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | Halloy 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | {{ VERSION }} 19 | CFBundleSupportedPlatforms 20 | 21 | MacOSX 22 | 23 | CFBundleVersion 24 | {{ BUILD }} 25 | CFBundleIconFile 26 | halloy.icns 27 | NSHighResolutionCapable 28 | 29 | NSMainNibFile 30 | 31 | NSSupportsAutomaticGraphicsSwitching 32 | 33 | CFBundleDisplayName 34 | Halloy 35 | NSRequiresAquaSystemAppearance 36 | NO 37 | CFBundleURLTypes 38 | 39 | 40 | CFBundleURLName 41 | Halloy 42 | CFBundleURLSchemes 43 | 44 | irc 45 | ircs 46 | halloy 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /assets/macos/Halloy.app/Contents/Resources/halloy.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/macos/Halloy.app/Contents/Resources/halloy.icns -------------------------------------------------------------------------------- /assets/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/screenshot.png -------------------------------------------------------------------------------- /assets/themes/ferra.toml: -------------------------------------------------------------------------------- 1 | [general] 2 | background = "#2b292d" 3 | horizontal_rule = "#323034" 4 | unread_indicator = "#ffa07a" 5 | border = "#4f474d" 6 | 7 | [text] 8 | primary = "#fecdb2" 9 | secondary = "#AB8A79" 10 | tertiary = "#d7bde2" 11 | success = "#b1b695" 12 | error = "#e06b75" 13 | 14 | [buffer] 15 | background = "#242226" 16 | background_text_input = "#1D1B1E" 17 | background_title_bar = "#222024" 18 | timestamp = "#685650" 19 | action = "#b1b695" 20 | topic = "#AB8A79" 21 | highlight = "#473f30" 22 | code = "#af8d9f" 23 | nickname = "#f6b6c9" 24 | url = "#d1d1e0" 25 | selection = "#453d41" 26 | border_selected = "#7D6E76" 27 | 28 | [buffer.server_messages] 29 | default = "#f5d76e" 30 | 31 | [buttons.primary] 32 | background = "#2b292d" 33 | background_hover = "#242226" 34 | background_selected = "#1d1b1e" 35 | background_selected_hover = "#0D0C0D" 36 | 37 | [buttons.secondary] 38 | background = "#323034" 39 | background_hover = "#3e3c41" 40 | background_selected = "#606155" 41 | background_selected_hover = "#6F7160" 42 | -------------------------------------------------------------------------------- /assets/windows/halloy.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/assets/windows/halloy.ico -------------------------------------------------------------------------------- /assets/windows/halloy.manifest: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PerMonitorV2, unaware 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/windows/halloy.rc: -------------------------------------------------------------------------------- 1 | #define IDI_ICON 0x101 2 | 3 | IDI_ICON ICON "halloy.ico" 4 | 5 | #define RT_MANIFEST 24 6 | 7 | 1 RT_MANIFEST "halloy.manifest" 8 | -------------------------------------------------------------------------------- /book/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /book/CNAME: -------------------------------------------------------------------------------- 1 | halloy.chat 2 | -------------------------------------------------------------------------------- /book/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Casper Rogild Storm", "Cory Forsstrom"] 3 | language = "en" 4 | multilingual = false 5 | src = "src" 6 | 7 | [output.html] 8 | cname = "halloy.chat" 9 | git-repository-url = "https://github.com/squidowl/halloy" 10 | edit-url-template = "https://github.com/squidowl/halloy/edit/main/book/{path}" 11 | -------------------------------------------------------------------------------- /book/src/README.md: -------------------------------------------------------------------------------- 1 | # Halloy 2 | 3 | 4 | 5 | ![halloy](./images/animation.gif) 6 | 7 | **Halloy** is an open-source IRC client written in Rust, with the [iced](https://github.com/iced-rs/iced/) GUI library. It aims to provide a simple and fast client for Mac, Windows, and Linux platforms. 8 | 9 | * IRCv3.2 capabilities 10 | * [account-notify](https://ircv3.net/specs/extensions/account-notify) 11 | * [away-notify](https://ircv3.net/specs/extensions/away-notify) 12 | * [batch](https://ircv3.net/specs/extensions/batch) 13 | * [cap-notify](https://ircv3.net/specs/extensions/capability-negotiation.html#cap-notify) 14 | * [chathistory](https://ircv3.net/specs/extensions/chathistory) 15 | * [chghost](https://ircv3.net/specs/extensions/chghost) 16 | * [echo-message](https://ircv3.net/specs/extensions/echo-message) 17 | * [extended-join](https://ircv3.net/specs/extensions/extended-join) 18 | * [invite-notify](https://ircv3.net/specs/extensions/invite-notify) 19 | * [labeled-response](https://ircv3.net/specs/extensions/labeled-response) 20 | * [message-tags](https://ircv3.net/specs/extensions/message-tags) 21 | * [Monitor](https://ircv3.net/specs/extensions/monitor) 22 | * [msgid](https://ircv3.net/specs/extensions/message-ids) 23 | * [multi-prefix](https://ircv3.net/specs/extensions/multi-prefix) 24 | * [read-marker](https://ircv3.net/specs/extensions/read-marker) 25 | * [sasl-3.1](https://ircv3.net/specs/extensions/sasl-3.1) 26 | * [server-time](https://ircv3.net/specs/extensions/server-time) 27 | * [setname](https://ircv3.net/specs/extensions/setname.html) 28 | * [Standard Replies](https://ircv3.net/specs/extensions/standard-replies) 29 | * [userhost-in-names](https://ircv3.net/specs/extensions/userhost-in-names) 30 | * [`UTF8ONLY`](https://ircv3.net/specs/extensions/utf8-only) 31 | * [`WHOX`](https://ircv3.net/specs/extensions/whox) 32 | * SASL support 33 | * DCC Send 34 | * Keyboard shortcuts 35 | * Auto-completion for nicknames, commands, and channels 36 | * Notifications support 37 | * Multiple channels at the same time across servers 38 | * Command bar for for quick actions 39 | * [Custom themes](https://themes.halloy.chat) 40 | * Portable mode 41 | 42 | ## Contributing 43 | Halloy is free and open source. You can find the source code as well as report issues and feature requests on [GitHub](https://github.com/squidowl/halloy). 44 | -------------------------------------------------------------------------------- /book/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | [Halloy](README.md) 4 | 5 | - [Installation](installation.md) 6 | - [Getting started](guides/getting-started.md) 7 | - [Get in touch](get-in-touch.md) 8 | 9 | # Guides 10 | 11 | - [Connect with soju](guides/connect-with-soju.md) 12 | - [Connect with ZNC](guides/connect-with-znc.md) 13 | - [Portable mode](guides/portable-mode.md) 14 | - [Multiple servers](guides/multiple-servers.md) 15 | - [Storing passwords in a File](guides/password-file.md) 16 | - [Text Formatting](guides/text-formatting.md) 17 | - [Monitor users](guides/monitor-users.md) 18 | - [YAML migration](guides/migrating-from-yaml.md) 19 | 20 | # Configuration 21 | 22 | - [Configuration](configuration/README.md) 23 | - [Actions](configuration/actions.md) 24 | - [Buffer](configuration/buffer.md) 25 | - [CTCP](configuration/ctcp.md) 26 | - [File Transfer](configuration/file_transfer.md) 27 | - [Font](configuration/font.md) 28 | - [Highlights](configuration/highlights.md) 29 | - [Keyboard](configuration/keyboard.md) 30 | - [Notifications](configuration/notifications.md) 31 | - [Pane](configuration/pane.md) 32 | - [Proxy](configuration/proxy.md) 33 | - [Preview](configuration/preview.md) 34 | - [Scale factor](configuration/scale-factor.md) 35 | - [Servers](configuration/servers.md) 36 | - [Sidebar](configuration/sidebar.md) 37 | - [Themes](configuration/themes/README.md) 38 | - [Community](configuration/themes/community.md) 39 | - [Base16](configuration/themes/base16.md) 40 | - [Tooltips](configuration/tooltips.md) 41 | - [URL Schemes](url-schemes.md) 42 | - [Commands](commands.md) 43 | -------------------------------------------------------------------------------- /book/src/commands.md: -------------------------------------------------------------------------------- 1 | # Commands 2 | 3 | Commands in Halloy are prefixed with `/`. 4 | 5 | Example 6 | 7 | ``` 8 | /me says halloy! 9 | ``` 10 | 11 | Halloy will first try to run below commands, and lastly send it directly to the server. 12 | 13 | | Command | Alias | Description | 14 | | --------- | ---------- | ------------------------------------------------------------- | 15 | | `away` | | Mark yourself as away. If already away, the status is removed | 16 | | `join` | `j` | Join channel(s) with optional key(s) | 17 | | `me` | `describe` | Send an action message to the channel | 18 | | `mode` | `m` | Set mode(s) on a channel or retrieve the current mode(s) set | 19 | | `monitor` | | System to notify when users become online/offline | 20 | | `msg` | `query` | Open a query with a nickname and send an optional message | 21 | | `nick` | | Change your nickname on the current server | 22 | | `part` | `leave` | Leave channel(s) with an optional reason | 23 | | `quit` | | Disconnect from the server with an optional reason | 24 | | `raw` | | Send data to the server without modifying it | 25 | | `topic` | `t` | Retrieve the topic of a channel or set a new topic | 26 | | `whois` | | Retrieve information about user(s) | 27 | | `ctcp` | | Client-To-Client requests | 28 | -------------------------------------------------------------------------------- /book/src/configuration/README.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | To edit configuration parameters, create a `config.toml` file located in your configuration directory: 4 | 5 | * Windows: `%AppData%\halloy` 6 | * Mac: `~/Library/Application Support/halloy` or `$HOME/.config/halloy` 7 | * Linux: `$XDG_CONFIG_HOME/halloy` or `$HOME/.config/halloy` 8 | 9 | > 💡 You can easily open the config file directory from command bar in Halloy 10 | 11 | The specification for the configuration file format ([TOML](https://toml.io/)) can be found at [https://toml.io/](https://toml.io/). 12 | 13 | Example config for connecting to [Libera](https://libera.chat/): 14 | 15 | ```toml 16 | [servers.liberachat] 17 | nickname = "halloy-user" 18 | server = "irc.libera.chat" 19 | channels = ["#halloy"] 20 | 21 | [buffer.channel.topic] 22 | enabled = true 23 | ``` 24 | -------------------------------------------------------------------------------- /book/src/configuration/ctcp.md: -------------------------------------------------------------------------------- 1 | # `[ctcp]` 2 | 3 | [Client-to-Client Protocol](https://modern.ircdocs.horse/ctcp) response settings. 4 | 5 | **Example** 6 | 7 | ```toml 8 | # Disable responses for TIME and VERSION responses 9 | 10 | [ctcp] 11 | time = false 12 | version = false 13 | ``` 14 | 15 | # `ping` 16 | 17 | Whether Halloy will respond to a [CTCP PING](https://modern.ircdocs.horse/ctcp#ping) message. 18 | 19 | ```toml 20 | # Type: boolean 21 | # Values: true, false 22 | # Default: true 23 | 24 | [ctcp] 25 | ping = true 26 | ``` 27 | 28 | # `source` 29 | 30 | Whether Halloy will respond to a [CTCP TIME](https://modern.ircdocs.horse/ctcp#source) message. 31 | 32 | ```toml 33 | # Type: boolean 34 | # Values: true, false 35 | # Default: true 36 | 37 | [ctcp] 38 | source = true 39 | ``` 40 | 41 | # `time` 42 | 43 | Whether Halloy will respond to a [CTCP TIME](https://modern.ircdocs.horse/ctcp#time) message. 44 | 45 | ```toml 46 | # Type: boolean 47 | # Values: true, false 48 | # Default: true 49 | 50 | [ctcp] 51 | time = true 52 | ``` 53 | 54 | # `version` 55 | 56 | Whether Halloy will respond to a [CTCP VERSION](https://modern.ircdocs.horse/ctcp#version) message. 57 | 58 | ```toml 59 | # Type: boolean 60 | # Values: true, false 61 | # Default: true 62 | 63 | [ctcp] 64 | version = true 65 | ``` 66 | -------------------------------------------------------------------------------- /book/src/configuration/file_transfer.md: -------------------------------------------------------------------------------- 1 | # `[file_transfer]` 2 | 3 | File transfer configuration options. 4 | 5 | ## `save_directory` 6 | 7 | Default directory to save files in. If not set, user will see a file dialog. 8 | 9 | ```toml 10 | # Type: string 11 | # Values: any string 12 | # Default: not set 13 | 14 | [file_transfer] 15 | save_directory = "/Users/halloy/Downloads" 16 | ``` 17 | 18 | ## `passive` 19 | 20 | If true, act as the "client" for the transfer. Requires the remote user act as the [server](#file_transferserver). 21 | 22 | ```toml 23 | # Type: boolean 24 | # Values: true, false 25 | # Default: true 26 | 27 | [file_transfer] 28 | passive = true 29 | ``` 30 | 31 | ## `timeout` 32 | 33 | Time (in seconds) to wait before timing out a transfer waiting to be accepted. 34 | 35 | ```toml 36 | # Type: integer 37 | # Values: any positive integer 38 | # Default: 300 39 | 40 | [file_transfer] 41 | timeout = 300 42 | ``` 43 | 44 | # `[file_transfer.server]` 45 | 46 | This section is **required** if `passive = false`. One side of the file transfer must 47 | operate as the "server", who the other user connects with to establish a connection. 48 | 49 | ## `public_address` 50 | 51 | Address advertised to the remote user to connect to. 52 | 53 | ```toml 54 | # Type: string 55 | # Values: any string 56 | # Default: not set 57 | 58 | [file_transfer.server] 59 | public_address = "" 60 | ``` 61 | 62 | ## `bind_address` 63 | 64 | Address to bind to when accepting connections. 65 | 66 | ```toml 67 | # Type: string 68 | # Values: any string 69 | # Default: not set 70 | 71 | [file_transfer.server] 72 | bind_address = "" 73 | ``` 74 | 75 | ## `bind_port_first` 76 | 77 | First port in port range to bind to. 78 | 79 | ```toml 80 | # Type: integer 81 | # Values: any positive integer 82 | # Default: not set 83 | 84 | [file_transfer.server] 85 | bind_port_first = "1024" 86 | ``` 87 | 88 | ## `bind_port_last` 89 | 90 | Last port in port range to bind to. 91 | 92 | ```toml 93 | # Type: integer 94 | # Values: any positive integer 95 | # Default: not set 96 | 97 | [file_transfer.server] 98 | bind_port_last = "5000" 99 | ``` 100 | -------------------------------------------------------------------------------- /book/src/configuration/font.md: -------------------------------------------------------------------------------- 1 | # `[font]` 2 | 3 | Application wide font settings. 4 | 5 | > ⚠️ Changes to font settings require an application restart to take effect. 6 | 7 | > 💡 If Halloy is unable to load the specified font & weight, an fallback font may be used. If the font looks wrong, double-check the family name and that the font family has the specified weight. 8 | 9 | ## `family` 10 | 11 | Monospaced font family to use. 12 | 13 | ```toml 14 | # Type: string 15 | # Values: any string 16 | # Default: not set 17 | # 18 | # Note: Iosevka Term is provided by the application, and used by default. 19 | 20 | [font] 21 | family = "Comic Mono" 22 | ``` 23 | 24 | ## `size` 25 | 26 | Font size. 27 | 28 | ```toml 29 | # Type: integer 30 | # Values: any positive integer 31 | # Default: 13 32 | 33 | [font] 34 | size = 13 35 | ``` 36 | 37 | ## `size` 38 | 39 | Font weight. 40 | 41 | ```toml 42 | # Type: string 43 | # Values: "thin", "extra-light", "light", "normal", "medium", "semibold", "bold", "extra-bold", and "black" 44 | # Default: "normal" 45 | 46 | [font] 47 | weight = "light" 48 | ``` 49 | 50 | ## `size` 51 | 52 | Bold font weight. If not set, then the font weight three steps above the regular font weight (e.g. font weight `"light"` → bold font weight `"semibold"`). 53 | 54 | ```toml 55 | # Type: string 56 | # Values: "thin", "extra-light", "light", "normal", "medium", "semibold", "bold", "extra-bold", and "black" 57 | # Default: not set 58 | 59 | [font] 60 | bold-weight = "semibold" 61 | ``` 62 | -------------------------------------------------------------------------------- /book/src/configuration/highlights.md: -------------------------------------------------------------------------------- 1 | # `[highlights]` 2 | 3 | Application wide highlights. 4 | 5 | **Example** 6 | 7 | ```toml 8 | # Enable nickname highlights only in channel #halloy. 9 | [highlights.nickname] 10 | exclude = ["*"] 11 | include = ["#halloy"] 12 | 13 | # Highlight on 'boat' and 'car' in any channel. 14 | [[highlights.match]] 15 | words = ["boat", "car"] 16 | case_insensitive = true 17 | 18 | # Highlight when regex matches in any channel except #noisy-channel. 19 | [[highlights.match]] 20 | regex = '''(?i)\bcasper\b''' 21 | exclude = ["#noisy-channel"] 22 | ``` 23 | 24 | ## `[highlights.nickname]` 25 | 26 | Nickname highlights. 27 | 28 | ### `exclude` 29 | 30 | Channels in which you won’t be highlighted. 31 | If you pass `["#halloy"]`, you won’t be highlighted in that channel. You can also exclude all channels by using a wildcard: `["*"]`. 32 | 33 | ```toml 34 | # Type: array of strings 35 | # Values: array of any strings 36 | # Default: [] 37 | 38 | [highlights.nickname] 39 | exclude = ["*"] 40 | ``` 41 | 42 | ### `include` 43 | 44 | Channels in which you will be highlighted, only useful when combined with `exclude = ["*"]`. 45 | If you pass `["#halloy"]`, you will only be highlighted in that channel. 46 | 47 | ```toml 48 | # Type: array of strings 49 | # Values: array of any strings 50 | # Default: ["*"] 51 | 52 | [highlights.nickname] 53 | exclude = ["*"] 54 | include = ["#halloy"] 55 | ``` 56 | 57 | ## `[[highlights.match]]` 58 | 59 | Highlight based on matches. 60 | 61 | ### `words` 62 | 63 | You can set words to be highlighted when they are written. 64 | 65 | Example shows word matches, which will trigger on `"word1"`, `"word2"` or `"word3"` in any channel. 66 | 67 | ```toml 68 | # Type: array of strings 69 | # Values: array of any strings 70 | # Default: [] 71 | 72 | [[highlights.match]] 73 | words = ["word1", "word2", "word3"] 74 | ``` 75 | 76 | ### `case_insensitive` 77 | 78 | This option is only available when using `words` as the match type. 79 | You can choose whether or not to trigger regardless of case. 80 | 81 | ```toml 82 | # Type: boolean 83 | # Values: true, false 84 | # Default: false 85 | 86 | [[highlights.match]] 87 | words = ["word1", "word2", "word3"] 88 | case_insensitive = true 89 | ``` 90 | 91 | ### `regex` 92 | 93 | Match based on regex. 94 | 95 |
96 | 97 | Use toml multi-line literal strings `'''\bfoo'd\b'''` when writing a regex. This allows you to write write the regex without 98 | escaping. You can also use a literal string `'\bfoo\b'`, but then you can't use `'` inside the string. 99 | 100 | Without literal strings, you'd have to write the above as `"\\bfoo'd\\b"` 101 | 102 |
103 | 104 | Example shows a regex that matches the word "casper", regardless of case and only when it appears as a whole word in any channel. 105 | 106 | ```toml 107 | # Type: string 108 | # Values: any string 109 | # Default: not set 110 | 111 | [[highlights.match]] 112 | regex = '''(?i)\bcasper\b''' 113 | ``` 114 | 115 | ### `exclude` 116 | 117 | Channels in which you won’t be highlighted. 118 | If you pass `["#halloy"]`, you won’t be highlighted in that channel. You can also exclude all channels by using a wildcard: `["*"]`. 119 | 120 | Example shows a regex match which will be excluded in from `#noisy-channel` 121 | 122 | ```toml 123 | # Type: array of strings 124 | # Values: array of any strings 125 | # Default: [] 126 | 127 | [[highlights.match]] 128 | regex = '''(?i)\bcasper\b''' 129 | exclude = ["#noisy-channel"] 130 | ``` 131 | 132 | ### `include` 133 | 134 | Channels in which you will be highlighted, only useful when combined with `exclude = ["*"]`. 135 | If you pass `["#halloy"]`, you will only be highlighted in that channel. 136 | 137 | Example shows a words match which will only try to match in `#halloy` channel. 138 | 139 | ```toml 140 | # Type: array of strings 141 | # Values: array of any strings 142 | # Default: ["*"] 143 | 144 | [[highlights.match]] 145 | words = ["word1", "word2", "word3"] 146 | exclude = ["*"] 147 | include = ["#halloy"] 148 | ``` 149 | -------------------------------------------------------------------------------- /book/src/configuration/notifications.md: -------------------------------------------------------------------------------- 1 | # `[notifications]` 2 | 3 | Customize and enable notifications. 4 | 5 | **Example** 6 | 7 | ```toml 8 | [notifications] 9 | direct_message = { sound = "peck", show_toast = true } 10 | 11 | [notifications.highlight] 12 | sound = "dong" 13 | exclude = ["NickServ", "#halloy"] 14 | ``` 15 | 16 | Following notifications are available: 17 | 18 | | Name | Description | 19 | | ----------------------- | -------------------------------------------------- | 20 | | `connected` | Triggered when a server is connected | 21 | | `direct_message` | Triggered when a direct message is received | 22 | | `disconnected` | Triggered when a server disconnects | 23 | | `file_transfer_request` | Triggered when a file transfer request is received | 24 | | `highlight` | Triggered when you were highlighted in a buffer | 25 | | `monitored_online` | Triggered when a user you're monitoring is online | 26 | | `monitored_offline` | Triggered when a user you're monitoring is offline | 27 | | `reconnected` | Triggered when a server reconnects | 28 | 29 | 30 | ## `sound` 31 | 32 | Notification sound. 33 | Supports both built-in sounds, and external sound files (`mp3`, `ogg`, `flac` or `wav` placed inside the `sounds` folder within the configuration directory). 34 | 35 | ```toml 36 | # Type: string 37 | # Values: "dong", "peck", "ring", "squeak", "whistle", "bonk", "sing" or external sound. 38 | # Default: not set 39 | 40 | [notifications.] 41 | sound = "dong" 42 | ``` 43 | 44 | ## `show_toast` 45 | 46 | Notification should trigger a OS toast. 47 | 48 | ```toml 49 | # Type: boolean 50 | # Values: true, false 51 | # Default: false 52 | 53 | [notifications.] 54 | show_toast = true 55 | ``` 56 | 57 | ## `delay` 58 | 59 | Delay in milliseconds before triggering the next notification. 60 | 61 | ```toml 62 | # Type: integer 63 | # Values: any positive integer 64 | # Default: 500 65 | 66 | [notifications.] 67 | delay = 250 68 | ``` 69 | 70 | ## `exclude` 71 | 72 | Exclude notifications for nicks (and/or channels in `highlight`'s case). 73 | 74 | Only available for `direct_message`, `highlight` and `file_transfer_request` 75 | notifications. 76 | 77 | You can also exclude all nicks/channels by using a wildcard: `["*"]` or `["all"]`. 78 | 79 | ```toml 80 | # Type: array of strings 81 | # Values: array of strings 82 | # Default: [] 83 | 84 | [notifications.] 85 | exclude = ["HalloyUser1"] 86 | 87 | [notifications.highlight] 88 | exclude = ["HalloyUser1", "#halloy"] 89 | ``` 90 | 91 | ## `include` 92 | 93 | Include notifications for nicks (and/or channels in `highlight`'s case). 94 | 95 | Only available for `direct_message`, `highlight` and `file_transfer_request` 96 | notifications. 97 | 98 | The include rule takes priority over exclude, so you can use both together. 99 | For example, you can exclude all nicks with `["*"]` for `direct_message` and 100 | then only include a few specific nicks to receive `direct_message` notifications 101 | from. 102 | 103 | ```toml 104 | # Type: array of strings 105 | # Values: array of strings 106 | # Default: [] 107 | 108 | [notifications.] 109 | include = ["HalloyUser1"] 110 | 111 | [notifications.highlight] 112 | include = ["HalloyUser1", "#halloy"] 113 | ``` 114 | -------------------------------------------------------------------------------- /book/src/configuration/pane.md: -------------------------------------------------------------------------------- 1 | # `[pane]` 2 | 3 | Pane settings for Halloy. A pane contains a [buffer](../configuration//buffer.md). 4 | 5 | ## `split_axis` 6 | 7 | Default axis used when splitting a pane (i.e. default orientation of the divider between panes). 8 | 9 | ```toml 10 | # Type: string 11 | # Values: "horizontal", "vertical" 12 | # Default: "horizontal" 13 | 14 | [pane] 15 | split_axis = "vertical" 16 | ``` 17 | -------------------------------------------------------------------------------- /book/src/configuration/proxy.md: -------------------------------------------------------------------------------- 1 | # `[proxy]` 2 | 3 | Proxy settings for Halloy. 4 | 5 | 1. [http](#proxyhttp) 6 | 2. [socks5](#proxysocks5) 7 | 3. [tor](#proxytor) 8 | 9 | ## `[proxy.http]` 10 | 11 | Http proxy settings. 12 | 13 | ### `host` 14 | 15 | Proxy host to connect to. 16 | 17 | ```toml 18 | # Type: string 19 | # Values: any string 20 | # Default: not set 21 | 22 | # Required 23 | 24 | [proxy.http] 25 | host = "192.168.1.100" 26 | ``` 27 | 28 | ### `port` 29 | 30 | Proxy port to connect on. 31 | 32 | ```toml 33 | # Type: integer 34 | # Values: any positive integer 35 | # Default: not set 36 | 37 | # Required 38 | 39 | [proxy.http] 40 | port = 1080 41 | ``` 42 | 43 | ### `username` 44 | 45 | Proxy username. 46 | 47 | ```toml 48 | # Type: string 49 | # Values: any string 50 | # Default: not set 51 | 52 | # Optional 53 | 54 | [proxy.http] 55 | username = "username" 56 | ``` 57 | 58 | ### `password` 59 | 60 | Proxy password. 61 | 62 | ```toml 63 | # Type: string 64 | # Values: any string 65 | # Default: not set 66 | 67 | # Optional 68 | 69 | [proxy.http] 70 | password = "password" 71 | ``` 72 | 73 | ## Example 74 | 75 | ```toml 76 | [proxy.http] 77 | host = "192.168.1.100" 78 | port = 1080 79 | username = "username" 80 | password = "password" 81 | ``` 82 | 83 | ## `[proxy.socks5]` 84 | 85 | Socks5 proxy settings. 86 | 87 | ### `host` 88 | 89 | Proxy host to connect to. 90 | 91 | ```toml 92 | # Type: string 93 | # Values: any string 94 | # Default: not set 95 | 96 | # Required 97 | 98 | [proxy.socks5] 99 | host = "192.168.1.100" 100 | ``` 101 | 102 | ### `port` 103 | 104 | Proxy port to connect on. 105 | 106 | ```toml 107 | # Type: integer 108 | # Values: any positive integer 109 | # Default: not set 110 | 111 | # Required 112 | 113 | [proxy.socks5] 114 | port = 1080 115 | ``` 116 | 117 | ### `username` 118 | 119 | Proxy username. 120 | 121 | ```toml 122 | # Type: string 123 | # Values: any string 124 | # Default: not set 125 | 126 | # Optional 127 | 128 | [proxy.socks5] 129 | username = "username" 130 | ``` 131 | 132 | ### `password` 133 | 134 | Proxy password. 135 | 136 | ```toml 137 | # Type: string 138 | # Values: any string 139 | # Default: not set 140 | 141 | # Optional 142 | 143 | [proxy.socks5] 144 | password = "password" 145 | ``` 146 | 147 | ## Example 148 | 149 | ```toml 150 | [proxy.socks5] 151 | host = "192.168.1.100" 152 | port = 1080 153 | username = "username" 154 | password = "password" 155 | ``` 156 | 157 | ## `[proxy.tor]` 158 | 159 | Tor proxy settings. Utilizes the [arti](https://arti.torproject.org/) to integrate Tor natively. 160 | It accepts no further configuration. 161 | 162 | ## Example 163 | 164 | ```toml 165 | [proxy.tor] 166 | ``` 167 | -------------------------------------------------------------------------------- /book/src/configuration/scale-factor.md: -------------------------------------------------------------------------------- 1 | # `[scale_factor]` 2 | 3 | Application wide scale factor. 4 | Note: `scale_factor` is a root key, so it must be placed before any section. 5 | 6 | ```toml 7 | # Type: float 8 | # Values: 0.1 .. 3.0 9 | # Default: 1.0 10 | 11 | scale_factor = 1.0 12 | ``` 13 | -------------------------------------------------------------------------------- /book/src/configuration/sidebar.md: -------------------------------------------------------------------------------- 1 | # `[sidebar]` 2 | 3 | Sidebar settings for Halloy. 4 | 5 | ## `unread_indicator` 6 | 7 | Unread buffer indicator style. 8 | 9 | ```toml 10 | # Type: string 11 | # Values: "dot", "title", "none" 12 | # Default: "dot" 13 | 14 | [sidebar] 15 | unread_indicator = "dot" 16 | ``` 17 | 18 | ## `position` 19 | 20 | Sidebar position within the application window. 21 | 22 | ```toml 23 | # Type: string 24 | # Values: "left", "top", "right", "bottom" 25 | # Default: "left" 26 | 27 | [sidebar] 28 | position = "left" 29 | ``` 30 | 31 | ## `max_width` 32 | 33 | Specify sidebar max width in pixels. Only used if `position` is `"left"` or `"right"`. 34 | 35 | ```toml 36 | # Type: integer 37 | # Values: any positive integer 38 | # Default: not set 39 | 40 | [sidebar] 41 | max_width = 200 42 | ``` 43 | 44 | ## `show_menu_button` 45 | 46 | Show or hide the user menu button in the sidemenu. 47 | 48 | ```toml 49 | # Type: bool 50 | # Values: true, false 51 | # Default: true 52 | 53 | [sidebar] 54 | show_menu_button = true 55 | ``` 56 | -------------------------------------------------------------------------------- /book/src/configuration/themes/README.md: -------------------------------------------------------------------------------- 1 | # Themes 2 | 3 | ## Example 4 | 5 | ```toml 6 | # Static 7 | theme = "ferra" 8 | 9 | # Dynamic 10 | theme = { light = "ferra-light", dark = "ferra" } 11 | ``` 12 | 13 | Note: `theme` is a root key, so it must be placed before any section. 14 | 15 | ## `theme` 16 | 17 | Specify the theme name(s) to use. The theme must correspond to a file located in the `themes` folder, which can be found in the Halloy configuration directory. The default theme in Halloy is [Ferra](https://github.com/casperstorm/ferra/). 18 | 19 | When a dynamic theme is used, Halloy will match the appearance of the OS. 20 | 21 | - **type**: string or object 22 | - **values**: `""`, `{ light = "", dark = "" }` 23 | - **default**: `"ferra"` 24 | 25 | > 💡 See all community created themes [here](./community.md) and base16 themes [here](./base16.md). 26 | 27 | ## Custom themes 28 | 29 | To create a custom theme for Halloy, simply place a theme file (with a `.toml` extension) inside the `themes` folder within the configuration directory. 30 | 31 | ```toml 32 | # Consider we have a theme called "foobar.toml" inside the themes folder. 33 | # Theme is a root key, so it has to be placed before any sections in your config file. 34 | 35 | theme = "foobar" 36 | # .. rest of the configuration file. 37 | ``` 38 | 39 | > 💡 Halloy has a built in theme editor which makes theme creation easier 40 | 41 | Each `""` is expected to be a valid hex color. If invalid, or if the key is removed, the color will fallback to transparent. A custom theme is structured as follows: 42 | 43 | ```toml 44 | [general] 45 | background = "" 46 | border = "" 47 | horizontal_rule = "" 48 | unread_indicator = "" 49 | 50 | [text] 51 | primary = "" 52 | secondary = "" 53 | tertiary = "" 54 | success = "" 55 | error = "" 56 | 57 | [buttons.primary] 58 | background = "" 59 | background_hover = "" 60 | background_selected = "" 61 | background_selected_hover = "" 62 | 63 | [buttons.secondary] 64 | background = "" 65 | background_hover = "" 66 | background_selected = "" 67 | background_selected_hover = "" 68 | 69 | [buffer] 70 | action = "" 71 | background = "" 72 | background_text_input = "" 73 | background_title_bar = "" 74 | border = "" 75 | border_selected = "" 76 | code = "" 77 | highlight = "" 78 | nickname = "" 79 | selection = "" 80 | timestamp = "" 81 | topic = "" 82 | url = "" 83 | 84 | [buffer.server_messages] 85 | # Set below if you want to have a unique color for each. 86 | # Otherwise simply set `default` to use that for all server messages. 87 | # 88 | # change_host = "" 89 | # join = "" 90 | # part = "" 91 | # quit = "" 92 | # reply_topic = "" 93 | # monitored_online = "" 94 | # monitored_offline = "" 95 | # standard_reply_fail = "" 96 | # standard_reply_warn = "" 97 | # standard_reply_note = "" 98 | # wallops = "" 99 | default = "" 100 | ``` 101 | > 💡 The default Ferra theme toml file can be viewed [here](https://github.com/squidowl/halloy/blob/main/assets/themes/ferra.toml). 102 | -------------------------------------------------------------------------------- /book/src/configuration/themes/base16.md: -------------------------------------------------------------------------------- 1 | # Base16 2 | 3 | The [base16](https://github.com/chriskempson/base16) color scheme framework 4 | includes hundreds of color schemes build using 16 colors. These color schemes have 5 | are compiled for Halloy in the 6 | [`4e554c4c/base16-halloy`](https://github.com/4e554c4c/base16-halloy) 7 | repository. 8 | 9 | To use these themes, download `themes.tar.gz` from the 10 | [latest release](https://github.com/4e554c4c/base16-halloy/releases/latest) 11 | and unpack it to the `themes` folder in the Halloy configuration directory. Then 12 | you can enable themes individually in `config.toml`. 13 | 14 | **Example** 15 | 16 | ```toml 17 | # Static 18 | theme = "base16-gruvbox-dark-hard" 19 | ``` 20 | -------------------------------------------------------------------------------- /book/src/configuration/themes/community.md: -------------------------------------------------------------------------------- 1 | # Community 2 | 3 | Discover community created themes for Halloy at https://themes.halloy.chat. 4 | -------------------------------------------------------------------------------- /book/src/configuration/tooltips.md: -------------------------------------------------------------------------------- 1 | # `[tooltips]` 2 | 3 | Control if tooltips are displayed or not. 4 | Note: `tooltips` is a root key, so it must be placed before any section. 5 | 6 | ```toml 7 | # Type: boolean 8 | # Values: true, false 9 | # Default: true 10 | 11 | tooltips = true 12 | ``` 13 | -------------------------------------------------------------------------------- /book/src/get-in-touch.md: -------------------------------------------------------------------------------- 1 | # Get in touch 2 | 3 | Join `#halloy` on `libera.chat` ([link](ircs://irc.libera.chat/#halloy)) if you have questions, looking for help or just want to say hello. 4 | For feature requests or reporting issues, please open a ticket on [GitHub](https://github.com/squidowl/halloy). 5 | 6 | ## Maintainers 7 | 8 | * andymandias ([https://github.com/andymandias](https://github.com/andymandias)) 9 | * casperstorm ([https://github.com/casperstorm](https://github.com/casperstorm)) 10 | * tarkah ([https://github.com/tarkah](https://github.com/tarkah)) 11 | 12 | ## Contributors 13 | 14 | Special thanks to all the people who makes Halloy happens 15 | 16 | * 4e554c4c ([https://github.com/4e554c4c](https://github.com/4e554c4c)) 17 | * a-kenji ([https://github.com/a-kenji](https://github.com/a-kenji)) 18 | * adamperkowski ([https://github.com/adamperkowski](https://github.com/adamperkowski)) 19 | * ameknite ([https://github.com/ameknite](https://github.com/ameknite)) 20 | * anarsoul ([https://github.com/anarsoul](https://github.com/anarsoul)) 21 | * auronandace ([https://github.com/auronandace](https://github.com/auronandace)) 22 | * bbb651 ([https://github.com/bbb651](https://github.com/bbb651)) 23 | * Daeraxa ([https://github.com/Daeraxa](https://github.com/Daeraxa)) 24 | * englut ([https://github.com/englut](https://github.com/englut)) 25 | * funkeleinhorn ([https://github.com/funkeleinhorn](https://github.com/funkeleinhorn)) 26 | * ikigai-gh ([https://github.com/ikigai-gh](https://github.com/ikigai-gh)) 27 | * jhff ([https://github.com/jhff](https://github.com/jhff)) 28 | * KaiKorla ([https://github.com/KaiKorla](https://github.com/KaiKorla)) 29 | * ljrk0 ([https://github.com/ljrk0](https://github.com/ljrk0)) 30 | * lodenrogue ([https://github.com/lodenrogue](https://github.com/lodenrogue)) 31 | * mikemykhaylov ([https://github.com/mikemykhaylov](https://github.com/mikemykhaylov)) 32 | * neilalexander ([https://github.com/neilalexander](https://github.com/neilalexander)) 33 | * oldgalileo ([https://github.com/oldgalileo](https://github.com/oldgalileo)) 34 | * petergam ([https://github.com/petergam](https://github.com/petergam)) 35 | * ramajd ([https://github.com/ramajd](https://github.com/ramajd)) 36 | * robert-groensfeld ([https://github.com/robert-groensfeld](https://github.com/robert-groensfeld)) 37 | * seth0xd ([https://github.com/seth0xd](https://github.com/seth0xd)) 38 | * spoisseroux ([https://github.com/spoisseroux](https://github.com/spoisseroux)) 39 | * Tea23 ([https://github.com/Tea23](https://github.com/Tea23)) 40 | * theRAAPster ([https://github.com/theRAAPster](https://github.com/theRAAPster)) 41 | * VioletSpace ([https://github.com/VioletSpace](https://github.com/VioletSpace)) 42 | * YouFoundAlpha ([https://github.com/YouFoundAlpha](https://github.com/YouFoundAlpha)) 43 | 44 | Did we forget you? We're sorry about that! Feel free to add yourself and create a pull request. 45 | -------------------------------------------------------------------------------- /book/src/guides/connect-with-soju.md: -------------------------------------------------------------------------------- 1 | # Connect with Soju 2 | 3 | To connect with a [**soju**](https://soju.im/) bouncer, the configuration below can be used as a template. Simply change so it fits your credentials. 4 | 5 | *as of 2025.1 Halloy supports chathistory, so the machinename(like @desktop) is no longer needed* 6 | 7 | ```toml 8 | [servers.libera] 9 | nickname = "casperstorm" 10 | username = "/irc.libera.chat" 11 | server = "irc.squidowl.org" 12 | port = 6697 13 | password = "" 14 | chathistory = true 15 | ``` 16 | 17 | You can enable infinite scrolling history as well, if you want to be able to load older messages 18 | 19 | ```toml 20 | [buffer.chathistory] 21 | infinite_scroll = true 22 | ``` 23 | -------------------------------------------------------------------------------- /book/src/guides/connect-with-znc.md: -------------------------------------------------------------------------------- 1 | # Connect with ZNC 2 | 3 | To connect with a [**ZNC**](https://wiki.znc.in/ZNC) bouncer, the configuration below can be used as a template. Simply change so it fits your credentials. 4 | 5 | ```toml 6 | [servers.libera] 7 | nickname = "/" 8 | server = "znc.example.com" 9 | password = "" 10 | 11 | # Depending on your ZNC setup you may need to apply these extra settings: 12 | 13 | # Does your znc use a self-signed or expired certificate? See: 14 | # https://halloy.chat/configuration/servers.html#dangerously_accept_invalid_certs 15 | 16 | # Does your znc listen on a different port? See: 17 | # https://halloy.chat/configuration/servers.html#port 18 | 19 | ``` 20 | -------------------------------------------------------------------------------- /book/src/guides/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | To get started with Halloy, you need to connect to at least one IRC server. The template config file has been set up with the [Libera](https://libera.chat/) server. However, there are many other servers available: [OFTC](https://www.oftc.net/), [Undernet](https://www.undernet.org/), [EFnet](http://www.efnet.org), [QuakeNet](https://www.quakenet.org/) and [many more](https://netsplit.de/networks/). Halloy can connect to multiple servers at the same time. 4 | 5 | Once connected to a server, you can join channels. This can be done automatically from the config file or manually using the join command: `/join #channel`[^1]. To find channels, you can either use the list command: `/list`, or [browse for channels online](https://netsplit.de/channels/). 6 | 7 | > 💡 Configuration in Halloy happens through a `config.toml` file. See [Configuration](../configuration/). 8 | 9 | Here are a few useful IRC commands for a new user[^2] 10 | 11 | | Command | Example | Description | 12 | | ----------------- | ---------------------- | ------------------------------------------ | 13 | | `/join` | `/join #halloy` | Join a new channel | 14 | | `/part` | `/part #halloy` | Part a channel | 15 | | `/nick` | `/nick halloyisgreat` | Change your nickname | 16 | | `/whois nickname` | `/whois halloyisgreat` | Displays information of nickname requested | 17 | | `/list *keyword*` | `/list *linux*` | List channels. Keyword is optional | 18 | 19 | 20 | [^1]: Channel names always start with a `#` symbol and do not contain spaces. 21 | [^2]: Find more commands [here](https://en.wikipedia.org/wiki/List_of_Internet_Relay_Chat_commands). 22 | -------------------------------------------------------------------------------- /book/src/guides/migrating-from-yaml.md: -------------------------------------------------------------------------------- 1 | # Migrating from YAML 2 | 3 | Halloy switched configuration file format from YAML to TOML ([PR-278](https://github.com/squidowl/halloy/pull/278)) 4 | This page will help you migrate your old `config.yaml` to a new `config.toml` file. 5 | 6 | The basic structure of a TOML file consists of key-value pairs, where keys are strings. There are no nested indentations like YAML, which makes it easier to read and write. Consider the following old YAML config with of two servers in Halloy: 7 | 8 | ```yaml 9 | servers: 10 | libera: 11 | nickname: foobar 12 | server: irc.libera.chat 13 | quakenet: 14 | nickname: barbaz 15 | server: underworld2.no.quakenet.org 16 | port: 6667 17 | use_tls: true 18 | ``` 19 | 20 | This now looks the following in TOML 21 | 22 | ```toml 23 | [servers.libera] 24 | nickname = "foobar" 25 | server = "irc.libera.chat" 26 | 27 | [servers.quakenet] 28 | nickname = "barbaz" 29 | server = "underworld2.no.quakenet.org" 30 | port = 6667 31 | use_tls = true 32 | ``` 33 | 34 | > 💡 You can convert YAML to TOML using a converter tool like [this one](https://transform.tools/yaml-to-toml). Just note that a few keys and values have be renamed during the conversion process. 35 | 36 | To migrate, and ensure everything is working, make sure to read through the [Configuration](../configuration) section of this book. Here, every configuration option is documented using TOML. 37 | -------------------------------------------------------------------------------- /book/src/guides/monitor-users.md: -------------------------------------------------------------------------------- 1 | # Monitor users 2 | 3 | Halloy has [monitor](https://ircv3.net/specs/extensions/monitor) support if the server has the IRCv3 Monitor extension. 4 | 5 | > 💡 A protocol for notification of when clients become online/offline 6 | 7 | To use the feature you need to add the user(s) you wish to monitor. This can be done in two ways: 8 | 9 | * You can add a list of user directly to the configuration file. [See configuration option.](../configuration/servers.md#monitor) 10 | * You can add users through `/monitor` directly in Halloy. 11 | 12 | Examples with the `/monitor` command: 13 | 14 | ```toml 15 | /monitor + casperstorm # Add user to list being monitored 16 | /monitor - casperstorm # Remove user from list being monitored 17 | /monitor c # Clear the list of users being monitored 18 | /monitor l # Get list of users being monitored 19 | /monitor s # For each user in the list being monitored, get their current status 20 | ``` 21 | -------------------------------------------------------------------------------- /book/src/guides/multiple-servers.md: -------------------------------------------------------------------------------- 1 | # Multiple servers 2 | 3 | Creating multiple `[servers]` sections lets you connect to multiple servers. 4 | All configuration options can be found [here](../configuration/servers.md). 5 | 6 | ```toml 7 | [servers.liberachat] 8 | nickname = "halloy-user" 9 | server = "irc.libera.chat" 10 | channels = ["#halloy"] 11 | 12 | [servers.oftc] 13 | nickname = "halloy-user" 14 | server = "irc.oftc.net" 15 | channels = ["#asahi-dev"] 16 | ``` 17 | -------------------------------------------------------------------------------- /book/src/guides/password-file.md: -------------------------------------------------------------------------------- 1 | # Storing passwords in a File 2 | 3 | If you need to commit your configuration file to a public repository, you can keep your passwords in a separate file for security. Below is an example of using a file for nickname password for NICKSERV. 4 | 5 | 6 | > 💡 Avoid adding extra lines in the password file, as they will be treated as part of the password. 7 | 8 | > 💡 Shell expansions (e.g. `"~/"` → `"/home/user/"`) are not supported in path strings. 9 | 10 | > 💡 Windows path strings should usually be specified as literal strings (e.g. `'C:\Users\Default\'`), otherwise directory separators will need to be escaped (e.g. `"C:\\Users\\Default\\"`). 11 | 12 | ```toml 13 | [servers.liberachat] 14 | nickname = "foobar" 15 | nick_password_file = "/home/user/config/halloy/password" 16 | server = "irc.libera.chat" 17 | channels = ["#halloy"] 18 | ``` 19 | -------------------------------------------------------------------------------- /book/src/guides/portable-mode.md: -------------------------------------------------------------------------------- 1 | # Portable mode 2 | 3 | To enable portable mode for Halloy, simply place the `config.toml` file in the same directory as the running executable. 4 | 5 | ``` 6 | . 7 | ├── Halloy.app 8 | └── config.toml 9 | ``` 10 | -------------------------------------------------------------------------------- /book/src/guides/text-formatting.md: -------------------------------------------------------------------------------- 1 | # Text Formatting 2 | 3 | Text can be formatted in Halloy by using the `/format` (or `/f`) command. 4 | 5 | ## Attributes 6 | 7 | Below is a table with the supported text attributes. 8 | 9 | | Action | Markdown | Token | 10 | | --------------------- | ----------------------- | ------------------------- | 11 | | _Italics_ | `_italic text_` | `$iitalic text$i` | 12 | | **Bold** | `__bold text__` | `$bbold text$b` | 13 | | **_Italic and Bold_** | `___italic and bold___` | `$b$iitalic and bold$i$b` | 14 | | ~~Strikethrough~~ | `~~strikethrough~~` | `$sstrikethrough$s` | 15 | | Underline | - | `$uunderline$u` | 16 | | Code | `` `code` `` | `$mcode$m` | 17 | | Spoiler | `\|\|spoiler\|\|` | - | 18 | 19 | Example 20 | 21 | ```json 22 | /format __this is bold__ $iand this is italic$i 23 | ``` 24 | 25 | Will render the following: 26 | 27 | > **this is bold** _and this is italic_ 28 | 29 | ## Color 30 | 31 | | Action | Token | 32 | | ----------------------------- | ------- | 33 | | Text color (fg) | `$c0` | 34 | | Text and background (fg & bg) | `$c0,1` | 35 | | End color | `$c` | 36 | 37 | The number next to the `$c` token indicates the color. For a comprehensive list of all numbers, see the following [ircdocs.horse documentation](https://modern.ircdocs.horse/formatting#colors-16-98). Below, the first 00 to 15 colors are defined and have been assigned aliases for convenience. 38 | 39 | Colors 40 | 41 | - 00 - white 42 | - 01 - black 43 | - 02 - blue 44 | - 03 - green 45 | - 04 - red 46 | - 05 - brown 47 | - 06 - magenta 48 | - 07 - orange 49 | - 08 - yellow 50 | - 09 - lightgreen 51 | - 10 - cyan 52 | - 11 - lightcyan 53 | - 12 - lightblue 54 | - 13 - pink 55 | - 14 - grey 56 | - 15 - lightgrey 57 | 58 | Example 59 | 60 | ``` 61 | /format $cred,lightgreenfoobar$c 62 | /format $c04,09foobar$c 63 | ``` 64 | 65 | Will both render the following: 66 | 67 | 68 | foobar 69 | 70 | 71 | ## Configuration 72 | 73 | By default, Halloy will only format text when using the `/format` command. This, however, can be changed with the `auto_format` configuration option: 74 | 75 | ```toml 76 | [buffer.text_input] 77 | auto_format = "disabled" | "markdown" | "all" 78 | ``` 79 | -------------------------------------------------------------------------------- /book/src/images/animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/book/src/images/animation.gif -------------------------------------------------------------------------------- /book/src/images/banner-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/book/src/images/banner-logo.png -------------------------------------------------------------------------------- /book/src/installation.md: -------------------------------------------------------------------------------- 1 | # Installing Halloy 2 | 3 | - [Pre-built binaries](#pre-built-binaries) 4 | - [Packaging status](#packaging-status) 5 | - [macOS](#macos) 6 | - [Homebrew](#homebrew) 7 | - [MacPorts](#macports) 8 | - [Linux](#linux) 9 | - [Flatpak](#flatpak) 10 | - [Snapcraft](#snapcraft) 11 | - [Windows](#windows) 12 | - [Winget](#winget) 13 | - [Build from source](#build-from-source) 14 | 15 | > 💡 To get the latest nightly version of Halloy, you can [build from source](#build-from-source). 16 | 17 | ## Pre-built binaries 18 | 19 | Download pre-built binaries from [GitHub](https://github.com/squidowl/halloy/releases) page. 20 | 21 | ### Packaging status 22 | 23 | 24 | Packaging status 25 | 26 | 27 | ### macOS 28 | 29 | The following third party repositories are available for macOS 30 | 31 | #### Homebrew 32 | 33 | ``` 34 | brew install --cask halloy 35 | ``` 36 | 37 | #### MacPorts 38 | 39 | ```sh 40 | sudo port install halloy 41 | ``` 42 | 43 | ### Linux 44 | 45 | The following third party repositories are available for Linux 46 | 47 | #### Flatpak 48 | 49 | [https://flathub.org/apps/org.squidowl.halloy](https://flathub.org/apps/org.squidowl.halloy) 50 | 51 | #### Snapcraft 52 | 53 | [https://snapcraft.io/halloy](https://snapcraft.io/halloy) 54 | 55 | ### Windows 56 | 57 | #### Winget 58 | 59 | ```sh 60 | winget install squidowl.halloy 61 | ``` 62 | 63 | ### Build from source 64 | 65 | Clone the Halloy GitHub repository into a directory of your choice and build with cargo. 66 | 67 | Requirements: 68 | 69 | * [Rust toolchain](https://www.rust-lang.org/tools/install) 70 | * [Git version control system](https://git-scm.com/) 71 | 72 | ```sh 73 | # Clone the repository 74 | git clone https://github.com/squidowl/halloy.git 75 | 76 | cd halloy 77 | 78 | # Build and run 79 | cargo build --release 80 | cargo run --release 81 | ``` 82 | -------------------------------------------------------------------------------- /book/src/url-schemes.md: -------------------------------------------------------------------------------- 1 | # URL Schemes 2 | 3 | Halloy is able to recognize different URL schemes. 4 | 5 | ## IRC and IRCS 6 | 7 | The IRC URL scheme is used to create a new connection to a server. 8 | The format is based on the [URI Syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax). 9 | 10 | ## Format 11 | 12 | ```url 13 | ://:/[#channel[,#channel]] 14 | ``` 15 | 16 | | Key | Description | 17 | | --------- | -------------------------------------------------------------- | 18 | | `scheme` | Can be `irc` or `ircs`. TLS is enabled if is `ircs`. | 19 | | `server` | Address for the server. Eg: `irc.libera.chat`. | 20 | | `port` | Optional. Defaults to `6667` (if `irc`) or `6697` (if `ircs`). | 21 | | `channel` | Optional. List of channels, separated by a comma. | 22 | 23 | ### Examples 24 | 25 | Below is a few URL examples. 26 | 27 | - **Connect to Libera:** 28 | [ircs://irc.libera.chat](ircs://irc.libera.chat) 29 | 30 | - **Connect to Libera and join #halloy:** 31 | [ircs://irc.libera.chat/#halloy](ircs://irc.libera.chat/#halloy) 32 | 33 | - **Connect to OFTC on port 9999 and join #oftc and #asahi-dev:** 34 | [ircs://irc.oftc.net:9999/#oftc,#asahi-dev](ircs://irc.oftc.net:9999/#oftc,#asahi-dev) 35 | 36 | ## Halloy 37 | 38 | The `halloy://` scheme is used to import themes. 39 | The syntax for that is `halloy:///theme?e=base64EncodedThemeData`. 40 | A list of community created themes can be found [here](./configuration/themes/community.md). 41 | -------------------------------------------------------------------------------- /book/theme/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/book/theme/favicon.png -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | #[cfg(windows)] 3 | { 4 | let _ = embed_resource::compile( 5 | "assets/windows/halloy.rc", 6 | embed_resource::NONE, 7 | ); 8 | windows_exe_info::versioninfo::link_cargo_env(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /config.toml: -------------------------------------------------------------------------------- 1 | # Halloy config. 2 | # 3 | # For a complete list of available options, 4 | # please visit https://halloy.chat/configuration/ 5 | 6 | [servers.liberachat] 7 | nickname = "__NICKNAME__" 8 | server = "irc.libera.chat" 9 | channels = ["#halloy"] 10 | -------------------------------------------------------------------------------- /data/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "data" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | 8 | [features] 9 | dev = [] 10 | 11 | [dependencies] 12 | thiserror = { workspace = true } 13 | futures = { workspace = true } 14 | tokio = { workspace = true, features = ["io-util", "fs"] } 15 | chrono = { workspace = true } 16 | bytes = { workspace = true } 17 | strum = { workspace = true } 18 | anyhow = { workspace = true } 19 | url = { workspace = true } 20 | tokio-stream = { workspace = true, features = ["time", "fs"] } 21 | timeago = { workspace = true } 22 | itertools = { workspace = true } 23 | emojis = { workspace = true } 24 | rand = { workspace = true } 25 | rand_chacha = { workspace = true } 26 | palette = { workspace = true } 27 | log = { workspace = true } 28 | 29 | base64 = "0.22.1" 30 | dirs-next = "2.0.0" 31 | xdg = "2.5.2" 32 | flate2 = "1.0" 33 | hex = "0.4.3" 34 | iced_core = "0.14.0-dev" 35 | seahash = "4.1.0" 36 | serde_json = "1.0" 37 | sha2 = "0.10.8" 38 | toml = "0.8.11" 39 | reqwest = { version = "0.12", features = ["json"] } 40 | fancy-regex = "0.14" 41 | walkdir = "2.5.0" 42 | nom = "7.1" 43 | const_format = "0.2.32" 44 | derive_more = { version = "2.0.1", features = ["full"] } 45 | image = "0.25.5" 46 | html-escape = "0.2.13" 47 | 48 | [dependencies.irc] 49 | path = "../irc" 50 | 51 | [dependencies.serde] 52 | version = "1.0" 53 | features = ["derive"] 54 | 55 | [lints] 56 | workspace = true 57 | -------------------------------------------------------------------------------- /data/build.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | use std::process::Command; 3 | 4 | const VERSION: &str = include_str!("../VERSION"); 5 | 6 | fn main() { 7 | let git_hash = Command::new("git") 8 | .args(["describe", "--always", "--dirty", "--exclude='*'"]) 9 | .output() 10 | .ok() 11 | .filter(|output| output.status.success()) 12 | .and_then(|x| String::from_utf8(x.stdout).ok()); 13 | 14 | println!("cargo:rerun-if-changed=../VERSION"); 15 | println!("cargo:rustc-env=VERSION={VERSION}"); 16 | 17 | if let Some(hash) = git_hash.as_ref() { 18 | println!("cargo:rustc-env=GIT_HASH={hash}"); 19 | } 20 | 21 | if git_hash.is_none() { 22 | return; 23 | } 24 | 25 | let Some(git_dir): Option = Command::new("git") 26 | .args(["rev-parse", "--git-dir"]) 27 | .output() 28 | .ok() 29 | .filter(|output| output.status.success()) 30 | .and_then(|x| String::from_utf8(x.stdout).ok()) 31 | else { 32 | return; 33 | }; 34 | // If heads starts pointing at something else (different branch) 35 | // we need to return 36 | let head = Path::new(&git_dir).join("HEAD"); 37 | if head.exists() { 38 | println!("cargo:rerun-if-changed={}", head.display()); 39 | } 40 | // if the thing head points to (branch) itself changes 41 | // we need to return 42 | let Some(head_ref): Option = Command::new("git") 43 | .args(["symbolic-ref", "HEAD"]) 44 | .output() 45 | .ok() 46 | .filter(|output| output.status.success()) 47 | .and_then(|x| String::from_utf8(x.stdout).ok()) 48 | else { 49 | return; 50 | }; 51 | let head_ref = Path::new(&git_dir).join(head_ref); 52 | if head_ref.exists() { 53 | println!("cargo:rerun-if-changed={}", head_ref.display()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /data/src/appearance.rs: -------------------------------------------------------------------------------- 1 | pub use theme::Theme; 2 | 3 | pub mod theme; 4 | 5 | #[derive(Debug, Clone)] 6 | pub struct Appearance { 7 | pub selected: Selected, 8 | pub all: Vec, 9 | } 10 | 11 | impl Default for Appearance { 12 | fn default() -> Self { 13 | Self { 14 | selected: Selected::default(), 15 | all: vec![Theme::default()], 16 | } 17 | } 18 | } 19 | 20 | #[derive(Debug, Clone)] 21 | pub enum Selected { 22 | Static(Theme), 23 | Dynamic { light: Theme, dark: Theme }, 24 | } 25 | 26 | impl Default for Selected { 27 | fn default() -> Self { 28 | Self::Static(Theme::default()) 29 | } 30 | } 31 | 32 | impl Selected { 33 | pub fn is_dynamic(&self) -> bool { 34 | match self { 35 | Selected::Static(_) => false, 36 | Selected::Dynamic { .. } => true, 37 | } 38 | } 39 | 40 | pub fn dynamic(light: Theme, dark: Theme) -> Selected { 41 | Selected::Dynamic { light, dark } 42 | } 43 | 44 | pub fn specific(theme: Theme) -> Selected { 45 | Selected::Static(theme) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /data/src/audio.rs: -------------------------------------------------------------------------------- 1 | use std::fs::read; 2 | use std::path::PathBuf; 3 | use std::sync::Arc; 4 | 5 | use serde::Deserialize; 6 | 7 | use crate::Config; 8 | 9 | #[derive(Debug, Clone)] 10 | pub struct Sound(Arc>); 11 | 12 | impl AsRef<[u8]> for Sound { 13 | fn as_ref(&self) -> &[u8] { 14 | &self.0 15 | } 16 | } 17 | 18 | impl Sound { 19 | pub fn load(name: &str) -> Result { 20 | let source = if let Ok(internal) = Internal::try_from(name) { 21 | internal.bytes() 22 | } else { 23 | let sound_path = find_external_sound(name)?; 24 | 25 | read(sound_path)? 26 | }; 27 | 28 | Ok(Sound(Arc::new(source))) 29 | } 30 | } 31 | 32 | #[derive(Debug, Clone, Deserialize)] 33 | #[serde(rename_all = "kebab-case")] 34 | pub enum Internal { 35 | Dong, 36 | Peck, 37 | Ring, 38 | Squeak, 39 | Whistle, 40 | Bonk, 41 | Sing, 42 | } 43 | 44 | impl Internal { 45 | pub fn bytes(&self) -> Vec { 46 | match self { 47 | Internal::Dong => include_bytes!("../../sounds/dong.ogg").to_vec(), 48 | Internal::Peck => include_bytes!("../../sounds/peck.ogg").to_vec(), 49 | Internal::Ring => include_bytes!("../../sounds/ring.ogg").to_vec(), 50 | Internal::Squeak => { 51 | include_bytes!("../../sounds/squeak.ogg").to_vec() 52 | } 53 | Internal::Whistle => { 54 | include_bytes!("../../sounds/whistle.ogg").to_vec() 55 | } 56 | Internal::Bonk => include_bytes!("../../sounds/bonk.ogg").to_vec(), 57 | Internal::Sing => include_bytes!("../../sounds/sing.ogg").to_vec(), 58 | } 59 | } 60 | } 61 | 62 | impl TryFrom<&str> for Internal { 63 | type Error = (); 64 | 65 | fn try_from(value: &str) -> Result { 66 | match value.to_lowercase().as_str() { 67 | "dong" => Ok(Self::Dong), 68 | "peck" => Ok(Self::Peck), 69 | "ring" => Ok(Self::Ring), 70 | "squeak" => Ok(Self::Squeak), 71 | "whistle" => Ok(Self::Whistle), 72 | "bonk" => Ok(Self::Bonk), 73 | "sing" => Ok(Self::Sing), 74 | _ => Err(()), 75 | } 76 | } 77 | } 78 | 79 | fn find_external_sound(sound: &str) -> Result { 80 | let sounds_dir = Config::sounds_dir(); 81 | 82 | for e in walkdir::WalkDir::new(sounds_dir.clone()) 83 | .into_iter() 84 | .filter_map(Result::ok) 85 | { 86 | if e.metadata().is_ok_and(|data| data.is_file()) 87 | && e.file_name() == sound 88 | { 89 | return Ok(e.path().to_path_buf()); 90 | } 91 | } 92 | 93 | let sounds_dir = 94 | if let Ok(sounds_dir) = sounds_dir.into_os_string().into_string() { 95 | format!(" in {sounds_dir}") 96 | } else { 97 | String::new() 98 | }; 99 | 100 | Err(LoadError::NoSoundFound(sound.to_string(), sounds_dir)) 101 | } 102 | 103 | #[derive(Debug, Clone, thiserror::Error)] 104 | pub enum LoadError { 105 | #[error(transparent)] 106 | File(Arc), 107 | #[error("sound \"{0}\" was not found{1}")] 108 | NoSoundFound(String, String), 109 | } 110 | 111 | impl From for LoadError { 112 | fn from(error: std::io::Error) -> Self { 113 | Self::File(Arc::new(error)) 114 | } 115 | } 116 | 117 | #[derive(Debug, thiserror::Error)] 118 | pub enum InitializationError { 119 | #[error("unsupported")] 120 | Unsupported, 121 | } 122 | -------------------------------------------------------------------------------- /data/src/channel.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::config; 4 | 5 | #[derive(Debug, Clone, Default, Deserialize, Serialize)] 6 | pub struct Settings { 7 | pub nicklist: Nicklist, 8 | pub topic: Topic, 9 | } 10 | 11 | impl From for Settings { 12 | fn from(config: config::buffer::Channel) -> Self { 13 | Self { 14 | nicklist: Nicklist::from(config.nicklist), 15 | topic: Topic::from(config.topic), 16 | } 17 | } 18 | } 19 | 20 | #[derive(Debug, Clone, Copy, Default, Deserialize)] 21 | #[serde(rename_all = "kebab-case")] 22 | pub enum Position { 23 | Left, 24 | #[default] 25 | Right, 26 | } 27 | 28 | #[derive(Debug, Clone, Copy, Deserialize, Serialize)] 29 | pub struct Nicklist { 30 | pub enabled: bool, 31 | } 32 | 33 | impl From for Nicklist { 34 | fn from(config: config::buffer::channel::Nicklist) -> Self { 35 | Nicklist { 36 | enabled: config.enabled, 37 | } 38 | } 39 | } 40 | 41 | impl Default for Nicklist { 42 | fn default() -> Self { 43 | Self { enabled: true } 44 | } 45 | } 46 | 47 | impl Nicklist { 48 | pub fn toggle_visibility(&mut self) { 49 | self.enabled = !self.enabled; 50 | } 51 | } 52 | 53 | #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] 54 | pub struct Topic { 55 | pub enabled: bool, 56 | } 57 | 58 | impl From for Topic { 59 | fn from(config: config::buffer::channel::Topic) -> Self { 60 | Topic { 61 | enabled: config.enabled, 62 | } 63 | } 64 | } 65 | 66 | impl Topic { 67 | pub fn toggle_visibility(&mut self) { 68 | self.enabled = !self.enabled; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /data/src/compression.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::io::prelude::*; 3 | 4 | use flate2::Compression; 5 | use flate2::read::GzDecoder; 6 | use flate2::write::GzEncoder; 7 | use serde::Serialize; 8 | use serde::de::DeserializeOwned; 9 | 10 | pub fn compress(value: &T) -> Result, Error> { 11 | let bytes = serde_json::to_vec(&value).map_err(Error::Encode)?; 12 | let mut encoder = GzEncoder::new(Vec::new(), Compression::fast()); 13 | encoder.write_all(&bytes).map_err(Error::Compression)?; 14 | encoder.finish().map_err(Error::Compression) 15 | } 16 | 17 | pub fn decompress(data: &[u8]) -> Result { 18 | let mut bytes = vec![]; 19 | let mut encoder = GzDecoder::new(data); 20 | encoder 21 | .read_to_end(&mut bytes) 22 | .map_err(Error::Decompression)?; 23 | serde_json::from_slice(&bytes).map_err(Error::Decode) 24 | } 25 | 26 | #[derive(Debug, thiserror::Error)] 27 | pub enum Error { 28 | #[error("compression failed")] 29 | Compression(io::Error), 30 | #[error("decompression failed")] 31 | Decompression(io::Error), 32 | #[error("encoding failed")] 33 | Encode(serde_json::Error), 34 | #[error("decoding failed")] 35 | Decode(serde_json::Error), 36 | } 37 | -------------------------------------------------------------------------------- /data/src/config/actions.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::dashboard::{BufferAction, BufferFocusedAction}; 4 | 5 | #[derive(Debug, Default, Clone, Deserialize)] 6 | pub struct Actions { 7 | #[serde(default)] 8 | pub sidebar: Sidebar, 9 | #[serde(default)] 10 | pub buffer: Buffer, 11 | } 12 | 13 | #[derive(Debug, Default, Clone, Deserialize)] 14 | pub struct Buffer { 15 | #[serde(default)] 16 | pub click_channel_name: BufferAction, 17 | #[serde(default)] 18 | pub click_highlight: BufferAction, 19 | #[serde(default)] 20 | pub click_username: BufferAction, 21 | #[serde(default)] 22 | pub local: BufferAction, 23 | #[serde(default)] 24 | pub message_channel: BufferAction, 25 | #[serde(default)] 26 | pub message_user: BufferAction, 27 | } 28 | 29 | #[derive(Debug, Default, Clone, Deserialize)] 30 | pub struct Sidebar { 31 | #[serde(default)] 32 | pub buffer: BufferAction, 33 | #[serde(default)] 34 | pub focused_buffer: Option, 35 | } 36 | -------------------------------------------------------------------------------- /data/src/config/buffer/away.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer}; 2 | 3 | #[derive(Debug, Clone, Copy, Default, Deserialize)] 4 | pub struct Away { 5 | #[serde(default)] 6 | pub appearance: Appearance, 7 | } 8 | 9 | impl Away { 10 | pub fn appearance(&self, is_user_away: bool) -> Option { 11 | is_user_away.then_some(self.appearance) 12 | } 13 | } 14 | 15 | #[derive(Debug, Clone, Copy, PartialEq)] 16 | pub enum Appearance { 17 | Dimmed(Option), 18 | Solid, 19 | } 20 | 21 | impl Default for Appearance { 22 | fn default() -> Self { 23 | Appearance::Dimmed(None) 24 | } 25 | } 26 | 27 | impl<'de> Deserialize<'de> for Appearance { 28 | fn deserialize(deserializer: D) -> Result 29 | where 30 | D: Deserializer<'de>, 31 | { 32 | #[derive(Deserialize)] 33 | #[serde(untagged)] 34 | enum AppearanceRepr { 35 | String(String), 36 | Struct(DimmedStruct), 37 | } 38 | 39 | #[derive(Deserialize)] 40 | struct DimmedStruct { 41 | dimmed: Option, 42 | } 43 | 44 | let repr = AppearanceRepr::deserialize(deserializer)?; 45 | match repr { 46 | AppearanceRepr::String(s) => match s.as_str() { 47 | "dimmed" => Ok(Appearance::Dimmed(None)), 48 | "solid" => Ok(Appearance::Solid), 49 | _ => Err(serde::de::Error::custom(format!( 50 | "unknown appearance: {s}", 51 | ))), 52 | }, 53 | AppearanceRepr::Struct(s) => Ok(Appearance::Dimmed(s.dimmed)), 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /data/src/config/buffer/channel.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use super::NicknameClickAction; 4 | use crate::buffer::Color; 5 | use crate::channel::Position; 6 | use crate::serde::default_bool_true; 7 | 8 | #[derive(Debug, Clone, Default, Deserialize)] 9 | pub struct Channel { 10 | #[serde(default)] 11 | pub nicklist: Nicklist, 12 | #[serde(default)] 13 | pub topic: Topic, 14 | #[serde(default)] 15 | pub message: Message, 16 | } 17 | 18 | #[derive(Debug, Clone, Default, Deserialize)] 19 | pub struct Message { 20 | #[serde(default)] 21 | pub nickname_color: Color, 22 | } 23 | 24 | #[derive(Debug, Clone, Deserialize)] 25 | pub struct Nicklist { 26 | #[serde(default = "default_bool_true")] 27 | pub enabled: bool, 28 | #[serde(default)] 29 | pub position: Position, 30 | #[serde(default)] 31 | pub color: Color, 32 | #[serde(default)] 33 | pub width: Option, 34 | #[serde(default)] 35 | pub alignment: Alignment, 36 | #[serde(default = "default_bool_true")] 37 | pub show_access_levels: bool, 38 | #[serde(default)] 39 | pub click: NicknameClickAction, 40 | } 41 | 42 | impl Default for Nicklist { 43 | fn default() -> Self { 44 | Self { 45 | enabled: default_bool_true(), 46 | position: Position::default(), 47 | color: Color::default(), 48 | width: Option::default(), 49 | alignment: Alignment::default(), 50 | show_access_levels: default_bool_true(), 51 | click: NicknameClickAction::default(), 52 | } 53 | } 54 | } 55 | 56 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)] 57 | #[serde(rename_all = "kebab-case")] 58 | pub enum Alignment { 59 | #[default] 60 | Left, 61 | Right, 62 | } 63 | 64 | #[derive(Debug, Clone, Copy, Deserialize)] 65 | pub struct Topic { 66 | #[serde(default)] 67 | pub enabled: bool, 68 | #[serde(default = "default_topic_banner_max_lines")] 69 | pub max_lines: u16, 70 | } 71 | 72 | impl Default for Topic { 73 | fn default() -> Self { 74 | Self { 75 | enabled: false, 76 | max_lines: default_topic_banner_max_lines(), 77 | } 78 | } 79 | } 80 | 81 | fn default_topic_banner_max_lines() -> u16 { 82 | 2 83 | } 84 | -------------------------------------------------------------------------------- /data/src/config/ctcp.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::serde::default_bool_true; 4 | 5 | #[derive(Debug, Clone, Deserialize)] 6 | pub struct Ctcp { 7 | #[serde(default = "default_bool_true")] 8 | pub ping: bool, 9 | #[serde(default = "default_bool_true")] 10 | pub source: bool, 11 | #[serde(default = "default_bool_true")] 12 | pub time: bool, 13 | #[serde(default = "default_bool_true")] 14 | pub version: bool, 15 | } 16 | 17 | impl Default for Ctcp { 18 | fn default() -> Self { 19 | Self { 20 | ping: default_bool_true(), 21 | source: default_bool_true(), 22 | time: default_bool_true(), 23 | version: default_bool_true(), 24 | } 25 | } 26 | } 27 | 28 | impl Ctcp { 29 | pub fn client_info(&self) -> String { 30 | let mut commands = vec!["ACTION", "CLIENTINFO", "DCC"]; 31 | 32 | if self.ping { 33 | commands.push("PING"); 34 | } 35 | 36 | if self.source { 37 | commands.push("SOURCE"); 38 | } 39 | 40 | if self.time { 41 | commands.push("TIME"); 42 | } 43 | 44 | if self.version { 45 | commands.push("VERSION"); 46 | } 47 | 48 | commands.join(" ") 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /data/src/config/file_transfer.rs: -------------------------------------------------------------------------------- 1 | use std::net::IpAddr; 2 | use std::num::NonZeroU16; 3 | use std::ops::RangeInclusive; 4 | use std::path::PathBuf; 5 | 6 | use serde::Deserialize; 7 | 8 | #[derive(Debug, Clone, Deserialize)] 9 | pub struct FileTransfer { 10 | /// Default directory to save files in. If not set, user will see a file dialog. 11 | #[serde(default)] 12 | pub save_directory: Option, 13 | /// If true, act as the "client" for the transfer. Requires the remote user act as the server. 14 | #[serde(default = "default_passive")] 15 | pub passive: bool, 16 | /// Time in seconds to wait before timing out a transfer waiting to be accepted. 17 | #[serde(default = "default_timeout")] 18 | pub timeout: u64, 19 | pub server: Option, 20 | } 21 | 22 | impl Default for FileTransfer { 23 | fn default() -> Self { 24 | Self { 25 | save_directory: None, 26 | passive: default_passive(), 27 | timeout: default_timeout(), 28 | server: None, 29 | } 30 | } 31 | } 32 | 33 | fn default_passive() -> bool { 34 | true 35 | } 36 | 37 | fn default_timeout() -> u64 { 38 | 60 * 5 39 | } 40 | 41 | #[derive(Debug, Clone)] 42 | pub struct Server { 43 | /// Address advertised to the remote user to connect to 44 | pub public_address: IpAddr, 45 | /// Address to bind to when accepting connections 46 | pub bind_address: IpAddr, 47 | /// Port range used to bind with 48 | pub bind_ports: RangeInclusive, 49 | } 50 | 51 | impl<'de> Deserialize<'de> for Server { 52 | fn deserialize(deserializer: D) -> Result 53 | where 54 | D: serde::Deserializer<'de>, 55 | { 56 | #[derive(Deserialize)] 57 | struct Data { 58 | public_address: IpAddr, 59 | bind_address: IpAddr, 60 | bind_port_first: NonZeroU16, 61 | bind_port_last: NonZeroU16, 62 | } 63 | 64 | let Data { 65 | public_address, 66 | bind_address, 67 | bind_port_first, 68 | bind_port_last, 69 | } = Data::deserialize(deserializer)?; 70 | 71 | if bind_port_last < bind_port_first { 72 | return Err(serde::de::Error::custom( 73 | "`bind_port_last` must be greater than or equal to `bind_port_first`", 74 | )); 75 | } 76 | 77 | Ok(Server { 78 | public_address, 79 | bind_address, 80 | bind_ports: bind_port_first.get()..=bind_port_last.get(), 81 | }) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /data/src/config/notification.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::audio::{self, Sound}; 4 | 5 | pub type Loaded = Notification; 6 | 7 | #[derive(Debug, Clone, Deserialize)] 8 | pub struct Notification { 9 | #[serde(default)] 10 | pub show_toast: bool, 11 | pub sound: Option, 12 | pub delay: Option, 13 | #[serde(default)] 14 | pub exclude: Vec, 15 | #[serde(default)] 16 | pub include: Vec, 17 | } 18 | 19 | impl Default for Notification { 20 | fn default() -> Self { 21 | Self { 22 | show_toast: false, 23 | sound: None, 24 | delay: Some(500), 25 | exclude: Vec::default(), 26 | include: Vec::default(), 27 | } 28 | } 29 | } 30 | 31 | impl Notification { 32 | pub fn should_notify(&self, targets: Vec) -> bool { 33 | let is_target_filtered = 34 | |list: &Vec, targets: &Vec| -> bool { 35 | let wildcards = ["*", "all"]; 36 | 37 | list.iter().any(|item| { 38 | wildcards.contains(&item.as_str()) || targets.contains(item) 39 | }) 40 | }; 41 | let target_included = is_target_filtered(&self.include, &targets); 42 | let target_excluded = is_target_filtered(&self.exclude, &targets); 43 | 44 | target_included || !target_excluded 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone, Deserialize)] 49 | pub struct Notifications { 50 | #[serde(default)] 51 | pub connected: Notification, 52 | #[serde(default)] 53 | pub disconnected: Notification, 54 | #[serde(default)] 55 | pub reconnected: Notification, 56 | #[serde(default)] 57 | pub direct_message: Notification, 58 | #[serde(default)] 59 | pub highlight: Notification, 60 | #[serde(default)] 61 | pub file_transfer_request: Notification, 62 | #[serde(default)] 63 | pub monitored_online: Notification, 64 | #[serde(default)] 65 | pub monitored_offline: Notification, 66 | } 67 | 68 | impl Default for Notifications { 69 | fn default() -> Self { 70 | Self { 71 | connected: Notification::default(), 72 | disconnected: Notification::default(), 73 | reconnected: Notification::default(), 74 | direct_message: Notification::default(), 75 | highlight: Notification::default(), 76 | file_transfer_request: Notification::default(), 77 | monitored_online: Notification::default(), 78 | monitored_offline: Notification::default(), 79 | } 80 | } 81 | } 82 | 83 | impl Notifications { 84 | pub fn load_sounds( 85 | &self, 86 | ) -> Result, audio::LoadError> { 87 | let load = |notification: &Notification| -> Result<_, audio::LoadError> { 88 | Ok(Notification { 89 | show_toast: notification.show_toast, 90 | sound: notification.sound.as_deref().map(Sound::load).transpose()?, 91 | delay: notification.delay, 92 | exclude: notification.exclude.to_owned(), 93 | include: notification.include.to_owned(), 94 | }) 95 | }; 96 | 97 | Ok(Notifications { 98 | connected: load(&self.connected)?, 99 | disconnected: load(&self.disconnected)?, 100 | reconnected: load(&self.reconnected)?, 101 | direct_message: load(&self.direct_message)?, 102 | highlight: load(&self.highlight)?, 103 | file_transfer_request: load(&self.file_transfer_request)?, 104 | monitored_online: load(&self.monitored_online)?, 105 | monitored_offline: load(&self.monitored_offline)?, 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /data/src/config/pane.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Clone, Deserialize, Default)] 4 | pub struct Pane { 5 | /// Default axis used when splitting a pane. 6 | #[serde(default)] 7 | pub split_axis: SplitAxis, 8 | } 9 | 10 | #[derive(Debug, Copy, Clone, Deserialize, Default)] 11 | #[serde(rename_all = "kebab-case")] 12 | pub enum SplitAxis { 13 | #[default] 14 | Horizontal, 15 | Vertical, 16 | } 17 | -------------------------------------------------------------------------------- /data/src/config/proxy.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | #[derive(Debug, Clone, Deserialize)] 4 | #[serde(rename_all = "snake_case")] 5 | pub enum Proxy { 6 | Http { 7 | host: String, 8 | port: u16, 9 | username: Option, 10 | password: Option, 11 | }, 12 | Socks5 { 13 | host: String, 14 | port: u16, 15 | username: Option, 16 | password: Option, 17 | }, 18 | Tor, 19 | } 20 | 21 | impl From for irc::connection::Proxy { 22 | fn from(proxy: Proxy) -> irc::connection::Proxy { 23 | match proxy { 24 | Proxy::Http { 25 | host, 26 | port, 27 | username, 28 | password, 29 | } => irc::connection::Proxy::Http { 30 | host, 31 | port, 32 | username, 33 | password, 34 | }, 35 | Proxy::Socks5 { 36 | host, 37 | port, 38 | username, 39 | password, 40 | } => irc::connection::Proxy::Socks5 { 41 | host, 42 | port, 43 | username, 44 | password, 45 | }, 46 | Proxy::Tor => irc::connection::Proxy::Tor, 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /data/src/config/sidebar.rs: -------------------------------------------------------------------------------- 1 | use serde::Deserialize; 2 | 3 | use crate::serde::default_bool_true; 4 | 5 | #[derive(Debug, Copy, Clone, Deserialize)] 6 | pub struct Sidebar { 7 | #[serde(default)] 8 | pub max_width: Option, 9 | #[serde(default)] 10 | pub unread_indicator: UnreadIndicator, 11 | #[serde(default)] 12 | pub position: Position, 13 | #[serde(default = "default_bool_true")] 14 | pub show_user_menu: bool, 15 | } 16 | 17 | #[derive(Debug, Copy, Clone, Deserialize, Default)] 18 | #[serde(rename_all = "kebab-case")] 19 | pub enum UnreadIndicator { 20 | #[default] 21 | Dot, 22 | Title, 23 | None, 24 | } 25 | 26 | #[derive(Debug, Copy, Clone, Deserialize, Default)] 27 | #[serde(rename_all = "kebab-case")] 28 | pub enum Position { 29 | #[default] 30 | Left, 31 | Right, 32 | Top, 33 | Bottom, 34 | } 35 | 36 | impl Position { 37 | pub fn is_horizontal(&self) -> bool { 38 | match self { 39 | Position::Left | Position::Right => false, 40 | Position::Top | Position::Bottom => true, 41 | } 42 | } 43 | } 44 | 45 | impl Default for Sidebar { 46 | fn default() -> Self { 47 | Sidebar { 48 | max_width: None, 49 | unread_indicator: UnreadIndicator::default(), 50 | position: Position::default(), 51 | show_user_menu: default_bool_true(), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /data/src/ctcp.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use irc::proto; 4 | 5 | // Reference: https://rawgit.com/DanielOaks/irc-rfcs/master/dist/draft-oakley-irc-ctcp-latest.html 6 | 7 | #[derive(Debug, Clone)] 8 | pub enum Command { 9 | Action, 10 | ClientInfo, 11 | DCC, 12 | Ping, 13 | Source, 14 | Version, 15 | Time, 16 | Unknown(String), 17 | } 18 | 19 | impl From<&str> for Command { 20 | fn from(command: &str) -> Self { 21 | match command.to_uppercase().as_ref() { 22 | "ACTION" => Command::Action, 23 | "CLIENTINFO" => Command::ClientInfo, 24 | "DCC" => Command::DCC, 25 | "PING" => Command::Ping, 26 | "SOURCE" => Command::Source, 27 | "VERSION" => Command::Version, 28 | "TIME" => Command::Time, 29 | _ => Command::Unknown(command.to_string()), 30 | } 31 | } 32 | } 33 | 34 | impl AsRef for Command { 35 | fn as_ref(&self) -> &str { 36 | match self { 37 | Command::Action => "ACTION", 38 | Command::ClientInfo => "CLIENTINFO", 39 | Command::DCC => "DCC", 40 | Command::Ping => "PING", 41 | Command::Source => "SOURCE", 42 | Command::Version => "VERSION", 43 | Command::Time => "TIME", 44 | Command::Unknown(command) => command.as_ref(), 45 | } 46 | } 47 | } 48 | 49 | #[derive(Debug)] 50 | pub struct Query<'a> { 51 | pub command: Command, 52 | pub params: Option<&'a str>, 53 | } 54 | 55 | pub fn is_query(text: &str) -> bool { 56 | text.starts_with('\u{1}') 57 | } 58 | 59 | pub fn parse_query(text: &str) -> Option { 60 | let query = text 61 | .strip_suffix('\u{1}') 62 | .unwrap_or(text) 63 | .strip_prefix('\u{1}')?; 64 | 65 | let (command, params) = if let Some((command, params)) = 66 | query.split_once(char::is_whitespace) 67 | { 68 | (command.to_uppercase(), Some(params)) 69 | } else { 70 | (query.to_uppercase(), None) 71 | }; 72 | 73 | let command = Command::from(command.as_str()); 74 | 75 | Some(Query { command, params }) 76 | } 77 | 78 | pub fn format(command: &Command, params: Option) -> String { 79 | let command = command.as_ref(); 80 | 81 | if let Some(params) = params { 82 | format!("\u{1}{command} {params}\u{1}") 83 | } else { 84 | format!("\u{1}{command}\u{1}") 85 | } 86 | } 87 | 88 | pub fn query_command( 89 | command: &Command, 90 | target: String, 91 | params: Option, 92 | ) -> proto::Command { 93 | proto::Command::PRIVMSG(target, format(command, params)) 94 | } 95 | 96 | pub fn query_message( 97 | command: &Command, 98 | target: String, 99 | params: Option, 100 | ) -> proto::Message { 101 | proto::command!("PRIVMSG", target, format(command, params)) 102 | } 103 | 104 | pub fn response_message( 105 | command: &Command, 106 | target: String, 107 | params: Option, 108 | ) -> proto::Message { 109 | proto::command!("NOTICE", target, format(command, params)) 110 | } 111 | -------------------------------------------------------------------------------- /data/src/dashboard.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::io; 3 | use std::path::PathBuf; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::buffer::{self, Buffer}; 8 | use crate::pane::Pane; 9 | use crate::serde::fail_as_none; 10 | use crate::{compression, environment}; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize)] 13 | pub struct Dashboard { 14 | pub pane: Pane, 15 | #[serde(default)] 16 | pub popout_panes: Vec, 17 | #[serde(default)] 18 | pub buffer_settings: BufferSettings, 19 | #[serde(default, deserialize_with = "fail_as_none")] 20 | pub focus_buffer: Option, 21 | } 22 | 23 | #[derive(Debug, Clone, Serialize, Deserialize, Default)] 24 | pub struct BufferSettings(HashMap); 25 | 26 | impl BufferSettings { 27 | pub fn get(&self, buffer: &buffer::Buffer) -> Option<&buffer::Settings> { 28 | self.0.get(&buffer.key()) 29 | } 30 | 31 | pub fn entry( 32 | &mut self, 33 | buffer: &buffer::Buffer, 34 | maybe_default: Option, 35 | ) -> &mut buffer::Settings { 36 | self.0 37 | .entry(buffer.key()) 38 | .or_insert_with(|| maybe_default.unwrap_or_default()) 39 | } 40 | } 41 | 42 | #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] 43 | #[serde(rename_all = "kebab-case")] 44 | pub enum BufferAction { 45 | #[default] 46 | NewPane, 47 | ReplacePane, 48 | NewWindow, 49 | } 50 | 51 | #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)] 52 | #[serde(rename_all = "kebab-case")] 53 | pub enum BufferFocusedAction { 54 | #[default] 55 | ClosePane, 56 | } 57 | 58 | impl Dashboard { 59 | pub fn load() -> Result { 60 | let path = path()?; 61 | 62 | let bytes = std::fs::read(path)?; 63 | 64 | Ok(compression::decompress(&bytes)?) 65 | } 66 | 67 | pub async fn save(self) -> Result<(), Error> { 68 | let path = path()?; 69 | 70 | let bytes = compression::compress(&self)?; 71 | 72 | tokio::fs::write(path, &bytes).await?; 73 | 74 | Ok(()) 75 | } 76 | } 77 | 78 | fn path() -> Result { 79 | let parent = environment::data_dir(); 80 | 81 | if !parent.exists() { 82 | std::fs::create_dir_all(&parent)?; 83 | } 84 | 85 | Ok(parent.join("dashboard.json.gz")) 86 | } 87 | 88 | #[derive(Debug, thiserror::Error)] 89 | pub enum Error { 90 | #[error(transparent)] 91 | Compression(#[from] compression::Error), 92 | #[error(transparent)] 93 | Io(#[from] io::Error), 94 | } 95 | -------------------------------------------------------------------------------- /data/src/environment.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::path::PathBuf; 3 | 4 | pub const VERSION: &str = env!("VERSION"); 5 | pub const GIT_HASH: Option<&str> = option_env!("GIT_HASH"); 6 | pub const CONFIG_FILE_NAME: &str = "config.toml"; 7 | pub const APPLICATION_ID: &str = "org.squidowl.halloy"; 8 | pub const WIKI_WEBSITE: &str = "https://halloy.chat"; 9 | pub const THEME_WEBSITE: &str = "https://themes.halloy.chat"; 10 | pub const MIGRATION_WEBSITE: &str = 11 | "https://halloy.chat/guides/migrating-from-yaml.html"; 12 | pub const RELEASE_WEBSITE: &str = 13 | "https://github.com/squidowl/halloy/releases/latest"; 14 | pub const SOURCE_WEBSITE: &str = "https://github.com/squidowl/halloy/"; 15 | 16 | pub fn formatted_version() -> String { 17 | let hash = GIT_HASH 18 | .map(|hash| format!(" ({hash})")) 19 | .unwrap_or_default(); 20 | 21 | format!("{VERSION}{hash}") 22 | } 23 | 24 | pub fn config_dir() -> PathBuf { 25 | portable_dir().unwrap_or_else(platform_specific_config_dir) 26 | } 27 | 28 | pub fn data_dir() -> PathBuf { 29 | portable_dir().unwrap_or_else(|| { 30 | dirs_next::data_dir() 31 | .expect("expected valid data dir") 32 | .join("halloy") 33 | }) 34 | } 35 | 36 | pub fn cache_dir() -> PathBuf { 37 | dirs_next::cache_dir() 38 | .expect("expected valid cache dir") 39 | .join("halloy") 40 | } 41 | 42 | /// Checks if a config file exists in the same directory as the executable. 43 | /// If so, it'll use that directory for both config & data dirs. 44 | fn portable_dir() -> Option { 45 | let exe = env::current_exe().ok()?; 46 | let dir = exe.parent()?; 47 | 48 | dir.join(CONFIG_FILE_NAME) 49 | .is_file() 50 | .then(|| dir.to_path_buf()) 51 | } 52 | 53 | fn platform_specific_config_dir() -> PathBuf { 54 | #[cfg(target_os = "macos")] 55 | { 56 | xdg_config_dir().unwrap_or_else(|| { 57 | dirs_next::config_dir() 58 | .expect("expected valid config dir") 59 | .join("halloy") 60 | }) 61 | } 62 | #[cfg(not(target_os = "macos"))] 63 | { 64 | dirs_next::config_dir() 65 | .expect("expected valid config dir") 66 | .join("halloy") 67 | } 68 | } 69 | 70 | #[cfg(target_os = "macos")] 71 | fn xdg_config_dir() -> Option { 72 | let config_dir = xdg::BaseDirectories::with_prefix("halloy") 73 | .ok() 74 | .and_then(|xdg| xdg.find_config_file(CONFIG_FILE_NAME))?; 75 | 76 | config_dir.parent().map(std::path::Path::to_path_buf) 77 | } 78 | -------------------------------------------------------------------------------- /data/src/file_transfer.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::time::Duration; 3 | 4 | use chrono::{DateTime, Utc}; 5 | 6 | pub use self::manager::Manager; 7 | pub use self::task::Task; 8 | use crate::user::Nick; 9 | use crate::{Server, dcc, server}; 10 | 11 | pub mod manager; 12 | pub mod task; 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 15 | pub struct Id(u16); 16 | 17 | impl From for Id { 18 | fn from(value: u16) -> Self { 19 | Id(value) 20 | } 21 | } 22 | 23 | impl From for u16 { 24 | fn from(id: Id) -> Self { 25 | id.0 26 | } 27 | } 28 | 29 | #[derive(Debug, Clone, PartialEq, Eq)] 30 | pub struct FileTransfer { 31 | pub id: Id, 32 | pub server: Server, 33 | pub created_at: DateTime, 34 | pub direction: Direction, 35 | pub remote_user: Nick, 36 | pub filename: String, 37 | pub size: u64, 38 | pub status: Status, 39 | } 40 | 41 | impl FileTransfer { 42 | pub fn progress(&self) -> f64 { 43 | match self.status { 44 | Status::Active { transferred, .. } => { 45 | transferred as f64 / self.size as f64 46 | } 47 | Status::Completed { .. } => 1.0, 48 | _ => 0.0, 49 | } 50 | } 51 | } 52 | 53 | impl PartialOrd for FileTransfer { 54 | fn partial_cmp(&self, other: &Self) -> Option { 55 | Some(self.cmp(other)) 56 | } 57 | } 58 | 59 | impl Ord for FileTransfer { 60 | fn cmp(&self, other: &Self) -> std::cmp::Ordering { 61 | self.created_at 62 | .cmp(&other.created_at) 63 | .reverse() 64 | .then_with(|| self.direction.cmp(&other.direction)) 65 | .then_with(|| self.remote_user.cmp(&other.remote_user)) 66 | .then_with(|| self.filename.cmp(&other.filename)) 67 | } 68 | } 69 | 70 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 71 | pub enum Direction { 72 | Sent, 73 | Received, 74 | } 75 | 76 | #[derive(Debug, Clone, PartialEq, Eq)] 77 | pub enum Status { 78 | /// Pending approval 79 | PendingApproval, 80 | /// Pending reverse confirmation 81 | PendingReverseConfirmation, 82 | /// Queued (needs an open port to begin) 83 | Queued, 84 | /// Ready (waiting for remote user to connect) 85 | Ready, 86 | /// Transfer is actively sending / receiving 87 | Active { transferred: u64, elapsed: Duration }, 88 | /// Transfer is complete 89 | Completed { elapsed: Duration, sha256: String }, 90 | /// An error occurred 91 | Failed { error: String }, 92 | } 93 | 94 | #[derive(Debug, Clone)] 95 | pub struct ReceiveRequest { 96 | pub from: Nick, 97 | pub dcc_send: dcc::Send, 98 | pub server: Server, 99 | pub server_handle: server::Handle, 100 | } 101 | 102 | #[derive(Debug)] 103 | pub struct SendRequest { 104 | pub to: Nick, 105 | pub path: PathBuf, 106 | pub server: Server, 107 | pub server_handle: server::Handle, 108 | } 109 | -------------------------------------------------------------------------------- /data/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::large_enum_variant, clippy::too_many_arguments)] 2 | 3 | pub use self::appearance::Theme; 4 | pub use self::buffer::Buffer; 5 | pub use self::command::Command; 6 | pub use self::config::Config; 7 | pub use self::dashboard::Dashboard; 8 | pub use self::input::Input; 9 | pub use self::message::Message; 10 | pub use self::mode::Mode; 11 | pub use self::notification::Notification; 12 | pub use self::pane::Pane; 13 | pub use self::preview::Preview; 14 | pub use self::server::Server; 15 | pub use self::shortcut::Shortcut; 16 | pub use self::target::Target; 17 | pub use self::url::Url; 18 | pub use self::user::User; 19 | pub use self::version::Version; 20 | pub use self::window::Window; 21 | 22 | pub mod appearance; 23 | pub mod audio; 24 | pub mod buffer; 25 | pub mod channel; 26 | pub mod client; 27 | pub mod command; 28 | mod compression; 29 | pub mod config; 30 | pub mod ctcp; 31 | pub mod dashboard; 32 | pub mod dcc; 33 | pub mod environment; 34 | pub mod file_transfer; 35 | pub mod history; 36 | pub mod input; 37 | pub mod isupport; 38 | pub mod log; 39 | pub mod message; 40 | pub mod mode; 41 | pub mod notification; 42 | pub mod pane; 43 | pub mod preview; 44 | pub mod serde; 45 | pub mod server; 46 | pub mod shortcut; 47 | pub mod stream; 48 | pub mod target; 49 | pub mod time; 50 | pub mod url; 51 | pub mod user; 52 | pub mod version; 53 | pub mod window; 54 | -------------------------------------------------------------------------------- /data/src/log.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::{fs, io}; 3 | 4 | use chrono::{DateTime, Utc}; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::environment; 8 | 9 | pub fn file() -> Result { 10 | let path = path()?; 11 | 12 | Ok(fs::OpenOptions::new() 13 | .write(true) 14 | .create(true) 15 | .append(false) 16 | .truncate(true) 17 | .open(path)?) 18 | } 19 | 20 | fn path() -> Result { 21 | let parent = environment::data_dir(); 22 | 23 | if !parent.exists() { 24 | fs::create_dir_all(&parent)?; 25 | } 26 | 27 | Ok(parent.join("halloy.log")) 28 | } 29 | 30 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 31 | pub struct Record { 32 | pub timestamp: DateTime, 33 | pub level: Level, 34 | pub message: String, 35 | } 36 | 37 | #[derive( 38 | Clone, 39 | Copy, 40 | PartialEq, 41 | Eq, 42 | PartialOrd, 43 | Ord, 44 | Debug, 45 | Hash, 46 | Serialize, 47 | Deserialize, 48 | strum::Display, 49 | )] 50 | #[strum(serialize_all = "UPPERCASE")] 51 | pub enum Level { 52 | Error, 53 | Warn, 54 | Info, 55 | Debug, 56 | Trace, 57 | } 58 | 59 | impl From for Level { 60 | fn from(level: log::Level) -> Self { 61 | match level { 62 | log::Level::Error => Level::Error, 63 | log::Level::Warn => Level::Warn, 64 | log::Level::Info => Level::Info, 65 | log::Level::Debug => Level::Debug, 66 | log::Level::Trace => Level::Trace, 67 | } 68 | } 69 | } 70 | 71 | #[derive(Debug, thiserror::Error)] 72 | pub enum Error { 73 | #[error(transparent)] 74 | Io(#[from] io::Error), 75 | #[error(transparent)] 76 | SetLog(#[from] log::SetLoggerError), 77 | #[error(transparent)] 78 | ParseLevel(#[from] log::ParseLevelError), 79 | } 80 | -------------------------------------------------------------------------------- /data/src/message/source.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | pub use self::server::Server; 4 | use crate::User; 5 | 6 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 7 | pub enum Source { 8 | User(User), 9 | Server(Option), 10 | Action(Option), 11 | Internal(Internal), 12 | } 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 15 | pub enum Internal { 16 | Status(Status), 17 | Logs, 18 | } 19 | 20 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 21 | pub enum Status { 22 | Success, 23 | Error, 24 | } 25 | 26 | pub mod server { 27 | #![allow(deprecated)] 28 | use serde::{Deserialize, Serialize}; 29 | 30 | use crate::user::Nick; 31 | 32 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 33 | #[serde(untagged)] 34 | pub enum Server { 35 | #[deprecated(note = "use Server::Details")] 36 | Kind(Kind), 37 | Details(Details), 38 | } 39 | 40 | impl Server { 41 | pub fn new(kind: Kind, nick: Option) -> Self { 42 | Self::Details(Details { kind, nick }) 43 | } 44 | 45 | pub fn kind(&self) -> Kind { 46 | match self { 47 | Server::Kind(kind) => *kind, 48 | Server::Details(details) => details.kind, 49 | } 50 | } 51 | 52 | pub fn nick(&self) -> Option<&Nick> { 53 | match self { 54 | Server::Kind(_) => None, 55 | Server::Details(details) => details.nick.as_ref(), 56 | } 57 | } 58 | } 59 | 60 | #[derive( 61 | Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, 62 | )] 63 | #[serde(rename_all = "lowercase")] 64 | pub enum Kind { 65 | Join, 66 | Part, 67 | Quit, 68 | ReplyTopic, 69 | ChangeHost, 70 | MonitoredOnline, 71 | MonitoredOffline, 72 | StandardReply(StandardReply), 73 | Wallops, 74 | } 75 | 76 | #[derive( 77 | Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, 78 | )] 79 | pub enum StandardReply { 80 | Fail, 81 | Warn, 82 | Note, 83 | } 84 | 85 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] 86 | pub struct Details { 87 | pub kind: Kind, 88 | pub nick: Option, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /data/src/notification.rs: -------------------------------------------------------------------------------- 1 | use crate::User; 2 | use crate::target::Channel; 3 | use crate::user::Nick; 4 | 5 | #[derive(Debug, PartialEq, Eq, Hash, Clone)] 6 | pub enum Notification { 7 | Connected, 8 | Disconnected, 9 | Reconnected, 10 | DirectMessage(User), 11 | Highlight { user: User, channel: Channel }, 12 | FileTransferRequest(Nick), 13 | MonitoredOnline(Vec), 14 | MonitoredOffline(Vec), 15 | } 16 | -------------------------------------------------------------------------------- /data/src/pane.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | use crate::Buffer; 4 | 5 | #[derive(Debug, Clone, Deserialize, Serialize)] 6 | pub enum Pane { 7 | Split { 8 | axis: Axis, 9 | ratio: f32, 10 | a: Box, 11 | b: Box, 12 | }, 13 | Buffer { 14 | buffer: Buffer, 15 | }, 16 | Empty, 17 | } 18 | 19 | #[derive(Debug, Clone, Copy, Deserialize, Serialize)] 20 | pub enum Axis { 21 | Horizontal, 22 | Vertical, 23 | } 24 | -------------------------------------------------------------------------------- /data/src/preview/cache.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use chrono::Utc; 4 | use serde::{Deserialize, Serialize}; 5 | use tokio::fs; 6 | use url::Url; 7 | 8 | use super::{Preview, image}; 9 | use crate::{config, environment}; 10 | 11 | #[derive(Debug, Serialize, Deserialize)] 12 | #[serde(rename_all = "snake_case")] 13 | pub enum State { 14 | Ok(Preview), 15 | Error, 16 | } 17 | 18 | pub async fn load(url: &Url, config: &config::Preview) -> Option { 19 | let path = state_path(url); 20 | 21 | if !path.exists() { 22 | return None; 23 | } 24 | 25 | let state: State = 26 | serde_json::from_slice(&fs::read(&path).await.ok()?).ok()?; 27 | 28 | // Ensure the actual image is cached 29 | match &state { 30 | State::Ok(Preview::Card(card)) => { 31 | if !card.image.path.exists() { 32 | super::fetch(card.image.url.clone(), config).await.ok()?; 33 | } 34 | } 35 | State::Ok(Preview::Image(image)) => { 36 | if !image.path.exists() { 37 | super::fetch(image.url.clone(), config).await.ok()?; 38 | } 39 | } 40 | State::Error => {} 41 | } 42 | 43 | Some(state) 44 | } 45 | 46 | pub async fn save(url: &Url, state: State) { 47 | let path = state_path(url); 48 | 49 | if let Some(parent) = path.parent().filter(|p| !p.exists()) { 50 | let _ = fs::create_dir_all(parent).await; 51 | } 52 | 53 | let Ok(bytes) = serde_json::to_vec(&state) else { 54 | return; 55 | }; 56 | 57 | let _ = fs::write(path, &bytes).await; 58 | } 59 | 60 | fn state_path(url: &Url) -> PathBuf { 61 | let hash = 62 | hex::encode(seahash::hash(url.as_str().as_bytes()).to_be_bytes()); 63 | 64 | environment::cache_dir() 65 | .join("previews") 66 | .join("state") 67 | .join(&hash[..2]) 68 | .join(&hash[2..4]) 69 | .join(&hash[4..6]) 70 | .join(format!("{hash}.json")) 71 | } 72 | 73 | pub(super) fn download_path(url: &Url) -> PathBuf { 74 | let hash = seahash::hash(url.as_str().as_bytes()); 75 | // Unique download path so if 2 identical URLs are downloading 76 | // at the same time, they don't clobber eachother 77 | let nanos = Utc::now().timestamp_nanos_opt().unwrap_or_default(); 78 | 79 | environment::cache_dir() 80 | .join("previews") 81 | .join("downloads") 82 | .join(format!("{hash}-{nanos}.part")) 83 | } 84 | 85 | pub(super) fn image_path( 86 | format: &image::Format, 87 | digest: &image::Digest, 88 | ) -> PathBuf { 89 | environment::cache_dir() 90 | .join("previews") 91 | .join("images") 92 | .join(&digest.as_ref()[..2]) 93 | .join(&digest.as_ref()[2..4]) 94 | .join(&digest.as_ref()[4..6]) 95 | .join(format!( 96 | "{}.{}", 97 | digest.as_ref(), 98 | format.extensions_str()[0] 99 | )) 100 | } 101 | -------------------------------------------------------------------------------- /data/src/preview/card.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use url::Url; 3 | 4 | use super::Image; 5 | 6 | #[derive(Debug, Clone, Serialize, Deserialize)] 7 | pub struct Card { 8 | pub url: Url, 9 | pub canonical_url: Url, 10 | pub image: Image, 11 | pub title: String, 12 | pub description: Option, 13 | } 14 | -------------------------------------------------------------------------------- /data/src/preview/image.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use derive_more::derive::AsRef; 4 | use serde::{Deserialize, Serialize}; 5 | use url::Url; 6 | 7 | use super::cache; 8 | 9 | pub type Format = image::ImageFormat; 10 | pub type Error = image::ImageError; 11 | 12 | /// SHA256 digest of image 13 | #[derive(Debug, Clone, Serialize, Deserialize, AsRef)] 14 | pub struct Digest(String); 15 | 16 | impl Digest { 17 | pub fn new(data: &[u8]) -> Self { 18 | Self(hex::encode(data)) 19 | } 20 | } 21 | 22 | #[derive(Debug, Clone, Serialize, Deserialize)] 23 | pub struct Image { 24 | #[serde(with = "serde_format")] 25 | pub format: Format, 26 | pub url: Url, 27 | pub digest: Digest, 28 | pub path: PathBuf, 29 | } 30 | 31 | impl Image { 32 | pub fn new(format: Format, url: Url, digest: Digest) -> Self { 33 | let path = cache::image_path(&format, &digest); 34 | 35 | Self { 36 | format, 37 | url, 38 | digest, 39 | path, 40 | } 41 | } 42 | } 43 | 44 | pub fn format(bytes: &[u8]) -> Option { 45 | image::guess_format(bytes).ok() 46 | } 47 | 48 | mod serde_format { 49 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 50 | 51 | use super::Format; 52 | 53 | pub fn serialize( 54 | format: &Format, 55 | serializer: S, 56 | ) -> Result { 57 | format.to_mime_type().serialize(serializer) 58 | } 59 | 60 | pub fn deserialize<'de, D: Deserializer<'de>>( 61 | deserializer: D, 62 | ) -> Result { 63 | let s = String::deserialize(deserializer)?; 64 | 65 | Format::from_mime_type(s) 66 | .ok_or(serde::de::Error::custom("invalid mime type")) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /data/src/serde.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Deserializer}; 2 | 3 | pub fn fail_as_none<'de, T, D>(deserializer: D) -> Result, D::Error> 4 | where 5 | T: Deserialize<'de>, 6 | D: Deserializer<'de>, 7 | { 8 | // We must fully consume valid json otherwise the error leaves the 9 | // deserializer in an invalid state and it'll still fail 10 | // 11 | // This assumes we always use a json format 12 | let intermediate = serde_json::Value::deserialize(deserializer)?; 13 | 14 | Ok(Option::::deserialize(intermediate).unwrap_or_default()) 15 | } 16 | 17 | pub fn default_bool_true() -> bool { 18 | true 19 | } 20 | -------------------------------------------------------------------------------- /data/src/time.rs: -------------------------------------------------------------------------------- 1 | use std::time::SystemTime; 2 | 3 | use chrono::{DateTime, Utc}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | #[derive( 7 | Debug, 8 | Clone, 9 | Copy, 10 | PartialEq, 11 | Eq, 12 | PartialOrd, 13 | Ord, 14 | Hash, 15 | Serialize, 16 | Deserialize, 17 | )] 18 | pub struct Posix(u64); 19 | 20 | impl Posix { 21 | pub fn now() -> Self { 22 | let nanos_since_epoch = SystemTime::now() 23 | .duration_since(SystemTime::UNIX_EPOCH) 24 | .expect("valid unix timestamp") 25 | .as_nanos() as u64; 26 | 27 | Self(nanos_since_epoch) 28 | } 29 | 30 | pub fn from_seconds(seconds: u64) -> Self { 31 | Self(seconds * 1_000_000_000) 32 | } 33 | 34 | pub fn as_nanos(&self) -> u64 { 35 | self.0 36 | } 37 | 38 | pub fn datetime(&self) -> Option> { 39 | let seconds = (self.0 / 1_000_000_000) as i64; 40 | let nanos = (self.0 % 1_000_000_000) as u32; 41 | 42 | DateTime::from_timestamp(seconds, nanos) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /data/src/version.rs: -------------------------------------------------------------------------------- 1 | use crate::environment::VERSION; 2 | 3 | const LATEST_REMOTE_RELEASE_URL: &str = 4 | "https://api.github.com/repos/squidowl/halloy/releases/latest"; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct Version { 8 | pub current: String, 9 | pub remote: Option, 10 | } 11 | 12 | impl Default for Version { 13 | fn default() -> Self { 14 | Self::new() 15 | } 16 | } 17 | 18 | impl Version { 19 | pub fn new() -> Version { 20 | let current = VERSION.to_owned(); 21 | 22 | Version { 23 | current, 24 | remote: None, 25 | } 26 | } 27 | 28 | pub fn is_old(&self) -> bool { 29 | match &self.remote { 30 | Some(remote) => &self.current != remote, 31 | None => false, 32 | } 33 | } 34 | } 35 | 36 | pub async fn latest_remote_version() -> Option { 37 | #[derive(serde::Deserialize)] 38 | struct Release { 39 | tag_name: String, 40 | } 41 | 42 | let client = reqwest::Client::builder() 43 | .user_agent("halloy") 44 | .build() 45 | .ok()?; 46 | 47 | let response = client 48 | .get(LATEST_REMOTE_RELEASE_URL) 49 | .header(reqwest::header::ACCEPT, "application/vnd.github.v3+json") 50 | .send() 51 | .await 52 | .ok()?; 53 | 54 | response 55 | .json::() 56 | .await 57 | .ok() 58 | .map(|release| release.tag_name) 59 | } 60 | -------------------------------------------------------------------------------- /data/src/window.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::{io, sync::Arc}; 3 | 4 | use iced_core::{Point, Size}; 5 | use serde::{Deserialize, Serialize}; 6 | use tokio::fs; 7 | 8 | use crate::environment; 9 | 10 | pub const MIN_SIZE: Size = Size::new(426.0, 240.0); 11 | 12 | pub mod position; 13 | pub mod size; 14 | 15 | #[derive(Debug, Clone, Copy, Serialize, Deserialize)] 16 | pub struct Window { 17 | #[serde(default, with = "serde_position")] 18 | pub position: Option, 19 | #[serde(default = "default_size", with = "serde_size")] 20 | pub size: Size, 21 | } 22 | 23 | impl Default for Window { 24 | fn default() -> Self { 25 | Self { 26 | position: None, 27 | size: default_size(), 28 | } 29 | } 30 | } 31 | 32 | pub fn default_size() -> Size { 33 | Size { 34 | width: 1024.0, 35 | height: 768.0, 36 | } 37 | } 38 | 39 | impl Window { 40 | pub async fn load() -> Result { 41 | let path = path()?; 42 | let bytes = fs::read(path).await?; 43 | let Window { position, size } = serde_json::from_slice(&bytes)?; 44 | 45 | let size = size.max(MIN_SIZE); 46 | let position = position 47 | .filter(|pos| pos.y.is_sign_positive() && pos.x.is_sign_positive()); 48 | 49 | Ok(Window { position, size }) 50 | } 51 | 52 | pub async fn save(self) -> Result<(), Error> { 53 | let path = path()?; 54 | 55 | let bytes = serde_json::to_vec(&self)?; 56 | fs::write(path, &bytes).await?; 57 | 58 | Ok(()) 59 | } 60 | } 61 | 62 | fn path() -> Result { 63 | let parent = environment::data_dir(); 64 | 65 | if !parent.exists() { 66 | std::fs::create_dir_all(&parent)?; 67 | } 68 | 69 | Ok(parent.join("window.json")) 70 | } 71 | 72 | #[derive(Debug, Clone, thiserror::Error)] 73 | pub enum Error { 74 | #[error(transparent)] 75 | Serde(Arc), 76 | #[error(transparent)] 77 | Io(Arc), 78 | } 79 | 80 | impl From for Error { 81 | fn from(error: serde_json::Error) -> Self { 82 | Self::Serde(Arc::new(error)) 83 | } 84 | } 85 | 86 | impl From for Error { 87 | fn from(error: io::Error) -> Self { 88 | Self::Io(Arc::new(error)) 89 | } 90 | } 91 | 92 | mod serde_position { 93 | use serde::{Deserializer, Serializer}; 94 | 95 | use super::*; 96 | 97 | #[derive(Deserialize, Serialize)] 98 | struct SerdePosition { 99 | x: f32, 100 | y: f32, 101 | } 102 | 103 | pub fn deserialize<'de, D>( 104 | deserializer: D, 105 | ) -> Result, D::Error> 106 | where 107 | D: Deserializer<'de>, 108 | { 109 | let maybe = Option::::deserialize(deserializer)?; 110 | 111 | Ok(maybe.map(|SerdePosition { x, y }| Point { x, y })) 112 | } 113 | 114 | pub fn serialize( 115 | position: &Option, 116 | serializer: S, 117 | ) -> Result { 118 | position 119 | .map(|p| SerdePosition { x: p.x, y: p.y }) 120 | .serialize(serializer) 121 | } 122 | } 123 | 124 | mod serde_size { 125 | use serde::{Deserializer, Serializer}; 126 | 127 | use super::*; 128 | 129 | #[derive(Deserialize, Serialize)] 130 | struct SerdeSize { 131 | width: f32, 132 | height: f32, 133 | } 134 | 135 | pub fn deserialize<'de, D>(deserializer: D) -> Result 136 | where 137 | D: Deserializer<'de>, 138 | { 139 | let SerdeSize { width, height } = SerdeSize::deserialize(deserializer)?; 140 | 141 | Ok(Size { width, height }) 142 | } 143 | 144 | pub fn serialize( 145 | size: &Size, 146 | serializer: S, 147 | ) -> Result { 148 | SerdeSize { 149 | width: size.width, 150 | height: size.height, 151 | } 152 | .serialize(serializer) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /data/src/window/position.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Copy, Clone, Default, Deserialize, Serialize)] 4 | pub struct Position { 5 | pub x: f32, 6 | pub y: f32, 7 | } 8 | 9 | impl Position { 10 | pub fn new(x: f32, y: f32) -> Self { 11 | Self { x, y } 12 | } 13 | } 14 | 15 | impl From for iced_core::Point { 16 | fn from(position: Position) -> Self { 17 | Self { 18 | x: position.x, 19 | y: position.y, 20 | } 21 | } 22 | } 23 | 24 | impl From for iced_core::window::Position { 25 | fn from(position: Position) -> Self { 26 | Self::Specific(position.into()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /data/src/window/size.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Copy, Clone, Deserialize, Serialize)] 4 | pub struct Size { 5 | pub width: f32, 6 | pub height: f32, 7 | } 8 | 9 | impl Size { 10 | pub const MIN_WIDTH: f32 = 426.0; 11 | pub const MIN_HEIGHT: f32 = 240.0; 12 | 13 | pub fn new(width: f32, height: f32) -> Self { 14 | Self { 15 | width: width.max(Self::MIN_WIDTH), 16 | height: height.max(Self::MIN_HEIGHT), 17 | } 18 | } 19 | } 20 | 21 | impl Default for Size { 22 | fn default() -> Self { 23 | Self { 24 | width: 1024.0, 25 | height: 768.0, 26 | } 27 | } 28 | } 29 | 30 | impl From for iced_core::Size { 31 | fn from(size: Size) -> Self { 32 | Self { 33 | width: size.width, 34 | height: size.height, 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /fonts/halloy-icons.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/fonts/halloy-icons.ttf -------------------------------------------------------------------------------- /fonts/iosevka-term-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/fonts/iosevka-term-bold.ttf -------------------------------------------------------------------------------- /fonts/iosevka-term-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/fonts/iosevka-term-italic.ttf -------------------------------------------------------------------------------- /fonts/iosevka-term-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/fonts/iosevka-term-regular.ttf -------------------------------------------------------------------------------- /ipc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ipc" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | 8 | [dependencies] 9 | url = { workspace = true } 10 | tokio = { workspace = true, features = ["rt", "fs", "process"] } 11 | futures = { workspace = true } 12 | thiserror = { workspace = true } 13 | rand = { workspace = true } 14 | rand_chacha = { workspace = true } 15 | 16 | interprocess = { version = "1.2.1", features = ["tokio_support"] } 17 | 18 | [dependencies.data] 19 | path = "../data" 20 | 21 | [lints] 22 | workspace = true 23 | -------------------------------------------------------------------------------- /ipc/src/client.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | use interprocess::local_socket::LocalSocketStream; 4 | 5 | use super::server; 6 | 7 | #[cfg(not(windows))] 8 | fn connect() -> Result { 9 | futures::executor::block_on(server::with_socket_path(|path| async { 10 | LocalSocketStream::connect(path) 11 | })) 12 | } 13 | 14 | #[cfg(windows)] 15 | fn connect() -> Result { 16 | let register_path = server::server_path_register_path(); 17 | let client_path = std::fs::read_to_string(register_path)?; 18 | 19 | LocalSocketStream::connect(client_path) 20 | } 21 | 22 | pub fn connect_and_send(url: impl AsRef<[u8]>) -> bool { 23 | match connect() { 24 | Ok(mut conn) => conn.write_all(url.as_ref()).is_ok(), 25 | Err(_) => false, 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ipc/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub use self::client::connect_and_send; 2 | pub use self::server::listen; 3 | 4 | mod client; 5 | pub(crate) mod server; 6 | -------------------------------------------------------------------------------- /ipc/src/server.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::{io, time}; 3 | 4 | use interprocess::local_socket::tokio::LocalSocketListener; 5 | 6 | #[cfg(windows)] 7 | fn server_path() -> String { 8 | use std::time; 9 | 10 | let nonce = time::SystemTime::now() 11 | .duration_since(time::UNIX_EPOCH) 12 | .unwrap() 13 | .as_secs(); 14 | 15 | format!("halloy-{nonce}") 16 | } 17 | 18 | #[cfg(windows)] 19 | pub fn server_path_register_path() -> PathBuf { 20 | data::environment::data_dir().join("ipc.txt") 21 | } 22 | 23 | #[cfg(not(windows))] 24 | pub fn socket_directory() -> PathBuf { 25 | data::environment::data_dir() 26 | } 27 | 28 | #[cfg(not(windows))] 29 | pub async fn with_socket_path(f: impl FnOnce(PathBuf) -> Fut) -> T 30 | where 31 | Fut: futures::Future, 32 | { 33 | let file = socket_directory().join("urlserver.sock"); 34 | f(file).await 35 | } 36 | 37 | #[cfg(not(windows))] 38 | pub async fn spawn_server() -> Result { 39 | with_socket_path(|path| async { 40 | let _ = tokio::fs::remove_file(path.clone()).await; 41 | LocalSocketListener::bind(path) 42 | }) 43 | .await 44 | } 45 | 46 | #[cfg(windows)] 47 | async fn spawn_server() -> Result { 48 | let path = server_path(); 49 | let named_pipe_addr_file = server_path_register_path(); 50 | 51 | tokio::fs::write(named_pipe_addr_file, &path).await?; 52 | LocalSocketListener::bind(path) 53 | } 54 | 55 | pub fn listen() -> futures::stream::BoxStream<'static, String> { 56 | use futures::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; 57 | use futures::stream::StreamExt; 58 | 59 | enum State { 60 | Uninitialized, 61 | Waiting(LocalSocketListener), 62 | } 63 | 64 | futures::stream::unfold(State::Uninitialized {}, move |state| async move { 65 | match state { 66 | State::Uninitialized => match spawn_server().await { 67 | Ok(server) => Some((None, State::Waiting(server))), 68 | Err(err) => { 69 | println!("error: {err:?}"); 70 | None 71 | } 72 | }, 73 | State::Waiting(server) => { 74 | let conn = server.accept().await; 75 | 76 | let Ok(conn) = conn else { 77 | return Some((None, State::Waiting(server))); 78 | }; 79 | 80 | let mut conn = BufReader::new(conn); 81 | let mut buffer = String::new(); 82 | 83 | let msg = tokio::time::timeout( 84 | time::Duration::from_millis(1_000), 85 | conn.read_line(&mut buffer), 86 | ) 87 | .await; 88 | 89 | let _ = conn.close().await; 90 | 91 | match msg { 92 | Ok(Ok(_)) => Some((Some(buffer), State::Waiting(server))), 93 | Err(_) | Ok(Err(_)) => Some((None, State::Waiting(server))), 94 | } 95 | } 96 | } 97 | }) 98 | .filter_map(|value| async move { value }) 99 | .boxed() 100 | } 101 | -------------------------------------------------------------------------------- /irc/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "irc" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | 8 | [dependencies] 9 | thiserror = { workspace = true } 10 | futures = { workspace = true } 11 | tokio = { workspace = true, features = ["full"] } 12 | bytes = { workspace = true } 13 | 14 | arti-client = { version = "0.26", default-features = false, features = ["rustls", "compression", "tokio", "static-sqlite"] } 15 | async-http-proxy = { version = "1.2.5", features = ["runtime-tokio", "basic-auth"] } 16 | fast-socks5 = "0.10.0" 17 | tokio-rustls = { version = "0.26.0", default-features = false, features = ["tls12", "ring"] } 18 | tokio-util = { version = "0.7", features = ["codec"] } 19 | rustls-native-certs = "0.8.1" 20 | rustls-pemfile = "2.1.1" 21 | xz2 = { version = "0.1.7", features = ["static"] } 22 | 23 | [dependencies.proto] 24 | path = "proto" 25 | package = "irc_proto" 26 | 27 | [lints] 28 | workspace = true 29 | -------------------------------------------------------------------------------- /irc/proto/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "irc_proto" 3 | version.workspace = true 4 | authors.workspace = true 5 | license.workspace = true 6 | edition.workspace = true 7 | 8 | [dependencies] 9 | itertools = "0.12.1" 10 | nom = "7.1" 11 | thiserror = "1.0.30" 12 | 13 | [lints] 14 | workspace = true 15 | -------------------------------------------------------------------------------- /irc/src/codec.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use bytes::BytesMut; 4 | use proto::{Message, format, parse}; 5 | use tokio_util::codec::{Decoder, Encoder}; 6 | 7 | pub type ParseResult = std::result::Result; 8 | 9 | pub struct Codec; 10 | 11 | impl Decoder for Codec { 12 | type Item = ParseResult; 13 | type Error = Error; 14 | 15 | fn decode( 16 | &mut self, 17 | src: &mut BytesMut, 18 | ) -> Result, Self::Error> { 19 | let Some(pos) = src.windows(2).position(|b| b == [b'\r', b'\n']) else { 20 | return Ok(None); 21 | }; 22 | 23 | let bytes = Vec::from(src.split_to(pos + 2)); 24 | 25 | Ok(Some(parse::message_bytes(bytes))) 26 | } 27 | } 28 | 29 | impl Encoder for Codec { 30 | type Error = Error; 31 | 32 | fn encode( 33 | &mut self, 34 | message: Message, 35 | dst: &mut BytesMut, 36 | ) -> Result<(), Self::Error> { 37 | let encoded = format::message(message); 38 | 39 | dst.extend(encoded.into_bytes()); 40 | 41 | Ok(()) 42 | } 43 | } 44 | 45 | #[derive(Debug, thiserror::Error)] 46 | pub enum Error { 47 | #[error(transparent)] 48 | Io(#[from] io::Error), 49 | } 50 | -------------------------------------------------------------------------------- /irc/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::large_enum_variant)] 2 | 3 | pub use tokio_util::codec::BytesCodec; 4 | 5 | pub use self::codec::Codec; 6 | pub use self::connection::Connection; 7 | 8 | pub mod codec; 9 | pub mod connection; 10 | pub use proto; 11 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2024" 2 | style_edition = "2024" 3 | 4 | group_imports = "StdExternalCrate" # unstable 5 | imports_granularity = "Module" # unstable 6 | max_width = 80 7 | unstable_features = true 8 | -------------------------------------------------------------------------------- /scripts/build-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | TARGET="halloy" 4 | ASSETS_DIR="assets" 5 | RELEASE_DIR="target/release" 6 | APP_NAME="Halloy.app" 7 | APP_TEMPLATE="$ASSETS_DIR/macos/$APP_NAME" 8 | APP_TEMPLATE_PLIST="$APP_TEMPLATE/Contents/Info.plist" 9 | APP_DIR="$RELEASE_DIR/macos" 10 | APP_BINARY="$RELEASE_DIR/$TARGET" 11 | APP_BINARY_DIR="$APP_DIR/$APP_NAME/Contents/MacOS" 12 | APP_EXTRAS_DIR="$APP_DIR/$APP_NAME/Contents/Resources" 13 | 14 | DMG_NAME="halloy.dmg" 15 | DMG_DIR="$RELEASE_DIR/macos" 16 | 17 | VERSION=$(cat VERSION) 18 | BUILD=$(git describe --always --dirty --exclude='*') 19 | 20 | # update version and build 21 | sed -i '' -e "s/{{ VERSION }}/$VERSION/g" "$APP_TEMPLATE_PLIST" 22 | sed -i '' -e "s/{{ BUILD }}/$BUILD/g" "$APP_TEMPLATE_PLIST" 23 | 24 | # build binary 25 | export MACOSX_DEPLOYMENT_TARGET="11.0" 26 | rustup target add x86_64-apple-darwin 27 | rustup target add aarch64-apple-darwin 28 | cargo build --release --target=x86_64-apple-darwin 29 | cargo build --release --target=aarch64-apple-darwin 30 | lipo "target/x86_64-apple-darwin/release/$TARGET" "target/aarch64-apple-darwin/release/$TARGET" -create -output "$APP_BINARY" 31 | 32 | # build app 33 | mkdir -p "$APP_BINARY_DIR" 34 | mkdir -p "$APP_EXTRAS_DIR" 35 | cp -fRp "$APP_TEMPLATE" "$APP_DIR" 36 | cp -fp "$APP_BINARY" "$APP_BINARY_DIR" 37 | touch -r "$APP_BINARY" "$APP_DIR/$APP_NAME" 38 | echo "Created '$APP_NAME' in '$APP_DIR'" 39 | -------------------------------------------------------------------------------- /scripts/build-windows-installer.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | WXS_FILE="wix/main.wxs" 3 | HALLOY_VERSION=$(cat VERSION).0 4 | 5 | # build the binary 6 | scripts/build-windows.sh 7 | 8 | # install latest wix 9 | dotnet tool install --global wix --version 5.0.2 10 | 11 | # add required wix extension 12 | wix extension add WixToolset.UI.wixext/5.0.2 13 | 14 | # build the installer 15 | wix build -pdbtype none -arch x64 -d PackageVersion=$HALLOY_VERSION $WXS_FILE -o target/release/halloy-installer.msi -ext WixToolset.UI.wixext 16 | -------------------------------------------------------------------------------- /scripts/build-windows.sh: -------------------------------------------------------------------------------- 1 | # Deprecated for now. 2 | # We should later use it for portable version of Halloy. 3 | 4 | #!/bin/bash 5 | EXE_NAME="halloy.exe" 6 | TARGET="x86_64-pc-windows-msvc" 7 | HALLOY_VERSION=$(cat VERSION).0 8 | 9 | # update package version on Cargo.toml 10 | cargo install cargo-edit 11 | cargo set-version $HALLOY_VERSION 12 | 13 | # build binary 14 | rustup target add $TARGET 15 | cargo build --release --target=$TARGET 16 | cp -fp target/$TARGET/release/$EXE_NAME target/release 17 | -------------------------------------------------------------------------------- /scripts/flatpak.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | 4 | flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo 5 | flatpak install --noninteractive --user flathub org.freedesktop.Platform//23.08 org.freedesktop.Sdk//23.08 org.freedesktop.Sdk.Extension.rust-stable//23.08 6 | 7 | flatpak install --noninteractive --user org.freedesktop.appstream-glib 8 | flatpak run --env=G_DEBUG=fatal-criticals org.freedesktop.appstream-glib validate assets/linux/org.squidowl.halloy.appdata.xml 9 | 10 | python3 -m pip install toml aiohttp 11 | curl -L 'https://github.com/flatpak/flatpak-builder-tools/raw/master/cargo/flatpak-cargo-generator.py' > /tmp/flatpak-cargo-generator.py 12 | python3 /tmp/flatpak-cargo-generator.py Cargo.lock -o assets/flatpak/generated-sources.json 13 | 14 | if [ "${CI}" != "yes" ] ; then 15 | flatpak-builder \ 16 | --install --force-clean --user -y \ 17 | --disable-rofiles-fuse --state-dir /var/tmp/halloy-flatpak-builder \ 18 | /var/tmp/halloy-flatpak-repo \ 19 | assets/flatpak/org.squidowl.halloy.json 20 | fi 21 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | cargo +nightly fmt --all 4 | -------------------------------------------------------------------------------- /scripts/generate-icons.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | cd $(git rev-parse --show-toplevel)/assets 4 | 5 | src=logo.png 6 | 7 | conv_opts="-colors 256 -background none -density 300" 8 | 9 | # the linux icon 10 | for size in "16" "24" "32" "48" "64" "96" "128" "256" "512"; do 11 | target="linux/icons/hicolor/${size}x${size}/apps" 12 | mkdir -p "$target" 13 | convert $conv_opts -resize "!${size}x${size}" "$src" "$target/org.squidowl.halloy.png" 14 | done 15 | -------------------------------------------------------------------------------- /scripts/package-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ARCH="x86_64" 4 | TARGET="halloy" 5 | VERSION=$(cat VERSION) 6 | PROFILE="release" 7 | ASSETS_DIR="assets/linux" 8 | RELEASE_DIR="target/$PROFILE" 9 | BINARY="$RELEASE_DIR/$TARGET" 10 | ARCHIVE_DIR="$RELEASE_DIR/archive" 11 | ARCHIVE_NAME="$TARGET-$VERSION-$ARCH-linux.tar.gz" 12 | ARCHIVE_PATH="$RELEASE_DIR/$ARCHIVE_NAME" 13 | 14 | build() { 15 | cargo build --profile $PROFILE 16 | } 17 | 18 | archive_name() { 19 | echo $ARCHIVE_NAME 20 | } 21 | 22 | archive_path() { 23 | echo $ARCHIVE_PATH 24 | } 25 | 26 | package() { 27 | build 28 | 29 | install -Dm755 $BINARY -t $ARCHIVE_DIR/bin 30 | install -Dm644 $ASSETS_DIR/org.squidowl.halloy.appdata.xml -t $ARCHIVE_DIR/share/metainfo 31 | install -Dm644 $ASSETS_DIR/org.squidowl.halloy.desktop -t $ARCHIVE_DIR/share/applications 32 | cp -r $ASSETS_DIR/icons $ARCHIVE_DIR/share/ 33 | 34 | tar czvf $ARCHIVE_PATH -C $ARCHIVE_DIR . 35 | 36 | echo "Packaged archive: $ARCHIVE_PATH" 37 | } 38 | 39 | case "$1" in 40 | "package") package;; 41 | "archive_name") archive_name;; 42 | "archive_path") archive_path;; 43 | *) 44 | echo "available commands: package, archive_name, archive_path" 45 | ;; 46 | esac 47 | -------------------------------------------------------------------------------- /scripts/package-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RELEASE_DIR="target/release" 4 | APP_DIR="$RELEASE_DIR/macos" 5 | APP_NAME="Halloy.app" 6 | DMG_NAME="halloy.dmg" 7 | DMG_DIR="$RELEASE_DIR/macos" 8 | 9 | # package dmg 10 | echo "Packing disk image..." 11 | ln -sf /Applications "$DMG_DIR/Applications" 12 | hdiutil create "$DMG_DIR/$DMG_NAME" -volname "Halloy" -fs HFS+ -srcfolder "$APP_DIR" -ov -format UDZO 13 | echo "Packed '$APP_NAME' in '$APP_DIR'" 14 | -------------------------------------------------------------------------------- /scripts/sign-macos.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RELEASE_DIR="target/release" 4 | APP_DIR="$RELEASE_DIR/macos" 5 | APP_NAME="Halloy.app" 6 | APP_PATH=$APP_DIR/$APP_NAME 7 | 8 | environment=("MACOS_CERTIFICATE" "MACOS_CERTIFICATE_PWD" "MACOS_CI_KEYCHAIN_PWD" "MACOS_CERTIFICATE_NAME" "MACOS_NOTARIZATION_APPLE_ID" "MACOS_NOTARIZATION_TEAM_ID" "MACOS_NOTARIZATION_PWD") 9 | for var in "${environment[@]}"; do 10 | if [[ -z "${!var}" ]]; then 11 | echo "Error: $var is not set" 12 | exit 1 13 | fi 14 | done 15 | 16 | echo "Decoding certificate" 17 | echo $MACOS_CERTIFICATE | base64 --decode > certificate.p12 18 | 19 | echo "Installing cert in a new key chain" 20 | security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain 21 | security default-keychain -s build.keychain 22 | security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain 23 | security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign 24 | security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CI_KEYCHAIN_PWD" build.keychain 25 | 26 | echo "Signing..." 27 | /usr/bin/codesign --force -s "$MACOS_CERTIFICATE_NAME" --options runtime $APP_PATH -v 28 | 29 | echo "Create keychain profile" 30 | xcrun notarytool store-credentials "notarytool-profile" --apple-id "$MACOS_NOTARIZATION_APPLE_ID" --team-id "$MACOS_NOTARIZATION_TEAM_ID" --password "$MACOS_NOTARIZATION_PWD" 31 | 32 | echo "Creating temp notarization archive" 33 | ditto -c -k --keepParent "$APP_PATH" "notarization.zip" 34 | 35 | echo "Notarize app" 36 | xcrun notarytool submit "notarization.zip" --keychain-profile "notarytool-profile" --wait 37 | 38 | echo "Attach staple" 39 | xcrun stapler staple $APP_PATH 40 | -------------------------------------------------------------------------------- /sounds/bonk.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/bonk.ogg -------------------------------------------------------------------------------- /sounds/dong.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/dong.ogg -------------------------------------------------------------------------------- /sounds/peck.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/peck.ogg -------------------------------------------------------------------------------- /sounds/ring.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/ring.ogg -------------------------------------------------------------------------------- /sounds/sing.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/sing.ogg -------------------------------------------------------------------------------- /sounds/squeak.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/squeak.ogg -------------------------------------------------------------------------------- /sounds/whistle.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/sounds/whistle.ogg -------------------------------------------------------------------------------- /src/appearance.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use data::appearance; 4 | use futures::stream::BoxStream; 5 | use futures::{StreamExt, stream}; 6 | use iced::advanced::graphics::futures::subscription; 7 | use iced::advanced::subscription::Hasher; 8 | use iced::{Subscription, futures}; 9 | pub use theme::Theme; 10 | use tokio::time; 11 | 12 | pub mod theme; 13 | 14 | #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] 15 | pub enum Mode { 16 | Dark, 17 | Light, 18 | } 19 | 20 | impl From for Mode { 21 | fn from(mode: dark_light::Mode) -> Self { 22 | match mode { 23 | dark_light::Mode::Dark => Mode::Dark, 24 | dark_light::Mode::Light => Mode::Light, 25 | // We map `Unspecified` to `Light`. 26 | dark_light::Mode::Unspecified => Mode::Light, 27 | } 28 | } 29 | } 30 | 31 | pub fn detect() -> Option { 32 | let Ok(mode) = dark_light::detect() else { 33 | return None; 34 | }; 35 | 36 | Some(Mode::from(mode)) 37 | } 38 | 39 | pub fn theme(selected: &data::appearance::Selected) -> data::appearance::Theme { 40 | match &selected { 41 | appearance::Selected::Static(theme) => theme.clone(), 42 | appearance::Selected::Dynamic { light, dark } => match detect() { 43 | Some(mode) => match mode { 44 | Mode::Dark => dark.clone(), 45 | Mode::Light => light.clone(), 46 | }, 47 | None => { 48 | log::warn!( 49 | "[theme] couldn't determine the OS appearance, using the default theme." 50 | ); 51 | appearance::Theme::default() 52 | } 53 | }, 54 | } 55 | } 56 | 57 | struct Appearance; 58 | 59 | impl subscription::Recipe for Appearance { 60 | type Output = Mode; 61 | 62 | fn hash(&self, state: &mut Hasher) { 63 | use std::hash::Hash; 64 | struct Marker; 65 | std::any::TypeId::of::().hash(state); 66 | } 67 | 68 | fn stream( 69 | self: Box, 70 | _input: subscription::EventStream, 71 | ) -> BoxStream<'static, Mode> { 72 | let interval = time::interval(Duration::from_secs(5)); 73 | 74 | stream::unfold( 75 | (interval, detect().unwrap_or(Mode::Light)), 76 | move |(mut interval, old_mode)| async move { 77 | loop { 78 | interval.tick().await; 79 | let new_mode = detect().unwrap_or(Mode::Light); 80 | 81 | if new_mode != old_mode { 82 | return Some((new_mode, (interval, new_mode))); 83 | } 84 | } 85 | }, 86 | ) 87 | .boxed() 88 | } 89 | } 90 | 91 | pub fn subscription() -> Subscription { 92 | subscription::from_recipe(Appearance) 93 | } 94 | -------------------------------------------------------------------------------- /src/appearance/theme.rs: -------------------------------------------------------------------------------- 1 | pub use data::appearance::theme::{ 2 | Buffer, Button, Buttons, Colors, General, ServerMessages, Text, 3 | color_to_hex, hex_to_color, 4 | }; 5 | 6 | use crate::widget::combo_box; 7 | 8 | pub mod button; 9 | pub mod checkbox; 10 | pub mod container; 11 | pub mod context_menu; 12 | pub mod menu; 13 | pub mod pane_grid; 14 | pub mod progress_bar; 15 | pub mod rule; 16 | pub mod scrollable; 17 | pub mod selectable_text; 18 | pub mod text; 19 | pub mod text_input; 20 | pub mod image; 21 | 22 | // TODO: If we use non-standard font sizes, we should consider 23 | // Config.font.size since it's user configurable 24 | pub const TEXT_SIZE: f32 = 13.0; 25 | pub const ICON_SIZE: f32 = 12.0; 26 | 27 | #[derive(Debug, Clone)] 28 | pub enum Theme { 29 | Selected(data::Theme), 30 | Preview { 31 | selected: data::Theme, 32 | preview: data::Theme, 33 | }, 34 | } 35 | 36 | impl Theme { 37 | pub fn preview(&self, theme: data::Theme) -> Self { 38 | match self { 39 | Theme::Selected(selected) | Theme::Preview { selected, .. } => { 40 | Self::Preview { 41 | selected: selected.clone(), 42 | preview: theme, 43 | } 44 | } 45 | } 46 | } 47 | 48 | pub fn selected(&self) -> Self { 49 | match self { 50 | Theme::Selected(selected) | Theme::Preview { selected, .. } => { 51 | Self::Selected(selected.clone()) 52 | } 53 | } 54 | } 55 | 56 | pub fn colors(&self) -> &Colors { 57 | match self { 58 | Theme::Selected(selected) => &selected.colors, 59 | Theme::Preview { preview, .. } => &preview.colors, 60 | } 61 | } 62 | } 63 | 64 | impl From for Theme { 65 | fn from(theme: data::Theme) -> Self { 66 | Theme::Selected(theme) 67 | } 68 | } 69 | 70 | impl Default for Theme { 71 | fn default() -> Self { 72 | Self::from(data::Theme::default()) 73 | } 74 | } 75 | 76 | impl iced::theme::Base for Theme { 77 | fn base(&self) -> iced::theme::Style { 78 | iced::theme::Style { 79 | background_color: self.colors().general.background, 80 | text_color: self.colors().text.primary, 81 | } 82 | } 83 | 84 | fn palette(&self) -> Option { 85 | None 86 | } 87 | } 88 | 89 | impl combo_box::Catalog for Theme {} 90 | -------------------------------------------------------------------------------- /src/appearance/theme/checkbox.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::checkbox::{Catalog, Status, Style, StyleFn}; 2 | use iced::{Border, Color}; 3 | 4 | use super::Theme; 5 | 6 | impl Catalog for Theme { 7 | type Class<'a> = StyleFn<'a, Self>; 8 | 9 | fn default<'a>() -> Self::Class<'a> { 10 | Box::new(primary) 11 | } 12 | 13 | fn style( 14 | &self, 15 | class: &Self::Class<'_>, 16 | status: iced::widget::checkbox::Status, 17 | ) -> Style { 18 | class(self, status) 19 | } 20 | } 21 | 22 | pub fn primary(theme: &Theme, status: Status) -> Style { 23 | let general = theme.colors().general; 24 | let text = theme.colors().text; 25 | 26 | match status { 27 | Status::Active { .. } => Style { 28 | background: iced::Background::Color(general.background), 29 | icon_color: text.primary, 30 | border: Border { 31 | color: general.border, 32 | width: 1.0, 33 | radius: 4.0.into(), 34 | }, 35 | text_color: Some(text.primary), 36 | }, 37 | Status::Hovered { .. } => Style { 38 | background: iced::Background::Color(general.background), 39 | icon_color: text.primary, 40 | border: Border { 41 | color: general.border, 42 | width: 1.0, 43 | radius: 4.0.into(), 44 | }, 45 | text_color: Some(text.primary), 46 | }, 47 | Status::Disabled { .. } => Style { 48 | background: iced::Background::Color(general.background), 49 | 50 | icon_color: Color { 51 | a: 0.2, 52 | ..text.primary 53 | }, 54 | border: Border { 55 | color: Color::TRANSPARENT, 56 | width: 1.0, 57 | radius: 4.0.into(), 58 | }, 59 | text_color: Some(Color { 60 | a: 0.2, 61 | ..text.primary 62 | }), 63 | }, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/appearance/theme/context_menu.rs: -------------------------------------------------------------------------------- 1 | use super::Theme; 2 | use crate::widget::context_menu::{Catalog, Style, StyleFn}; 3 | 4 | impl Catalog for Theme { 5 | type Class<'a> = StyleFn<'a, Self>; 6 | 7 | fn default<'a>() -> Self::Class<'a> { 8 | Box::new(super::container::tooltip) 9 | } 10 | 11 | fn style(&self, class: &Self::Class<'_>) -> Style { 12 | class(self) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/appearance/theme/image.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::image::{Catalog, Style, StyleFn}; 2 | 3 | use super::Theme; 4 | 5 | impl Catalog for Theme { 6 | type Class<'a> = StyleFn<'a, Self>; 7 | 8 | fn default<'a>() -> Self::Class<'a> { 9 | Box::new(primary) 10 | } 11 | 12 | fn style(&self, class: &Self::Class<'_>) -> Style { 13 | class(self) 14 | } 15 | } 16 | 17 | pub fn primary(_theme: &Theme) -> Style { 18 | Style::default() 19 | } 20 | -------------------------------------------------------------------------------- /src/appearance/theme/menu.rs: -------------------------------------------------------------------------------- 1 | pub use iced::widget::overlay::menu::Style; 2 | use iced::widget::overlay::menu::{Catalog, StyleFn}; 3 | use iced::{Background, Border}; 4 | 5 | use super::Theme; 6 | 7 | impl Catalog for Theme { 8 | type Class<'a> = StyleFn<'a, Self>; 9 | 10 | fn default<'a>() -> StyleFn<'a, Self> { 11 | Box::new(primary) 12 | } 13 | 14 | fn style(&self, class: &StyleFn<'_, Self>) -> Style { 15 | class(self) 16 | } 17 | } 18 | 19 | pub fn primary(theme: &Theme) -> Style { 20 | let buttons = theme.colors().buttons; 21 | let general = theme.colors().general; 22 | let text = theme.colors().text; 23 | 24 | Style { 25 | text_color: text.primary, 26 | background: Background::Color(general.background), 27 | border: Border { 28 | width: 1.0, 29 | radius: 4.0.into(), 30 | color: general.border, 31 | }, 32 | selected_text_color: text.primary, 33 | selected_background: Background::Color( 34 | buttons.primary.background_hover, 35 | ), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/appearance/theme/pane_grid.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::pane_grid::{Catalog, Highlight, Line, Style, StyleFn}; 2 | use iced::{Background, Border, Color}; 3 | 4 | use super::Theme; 5 | 6 | impl Catalog for Theme { 7 | type Class<'a> = StyleFn<'a, Self>; 8 | 9 | fn default<'a>() -> StyleFn<'a, Self> { 10 | Box::new(primary) 11 | } 12 | 13 | fn style(&self, class: &StyleFn<'_, Self>) -> Style { 14 | class(self) 15 | } 16 | } 17 | 18 | pub fn primary(theme: &Theme) -> Style { 19 | let general = theme.colors().general; 20 | 21 | Style { 22 | hovered_region: Highlight { 23 | background: Background::Color(Color { 24 | a: 0.2, 25 | ..general.border 26 | }), 27 | border: Border { 28 | width: 1.0, 29 | color: general.border, 30 | radius: 4.0.into(), 31 | }, 32 | }, 33 | picked_split: Line { 34 | color: general.border, 35 | width: 4.0, 36 | }, 37 | hovered_split: Line { 38 | color: general.border, 39 | width: 4.0, 40 | }, 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/appearance/theme/progress_bar.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::progress_bar::{Catalog, Style, StyleFn}; 2 | 3 | use super::Theme; 4 | 5 | impl Catalog for Theme { 6 | type Class<'a> = StyleFn<'a, Self>; 7 | 8 | fn default<'a>() -> Self::Class<'a> { 9 | Box::new(primary) 10 | } 11 | 12 | fn style(&self, class: &Self::Class<'_>) -> Style { 13 | class(self) 14 | } 15 | } 16 | 17 | pub fn primary(theme: &Theme) -> Style { 18 | let general = theme.colors().general; 19 | let text = theme.colors().text; 20 | 21 | Style { 22 | background: iced::Background::Color(general.background), 23 | bar: iced::Background::Color(text.tertiary), 24 | border: iced::Border { 25 | color: iced::Color::TRANSPARENT, 26 | width: 0.0, 27 | radius: 0.0.into(), 28 | }, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/appearance/theme/rule.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::rule::{Catalog, FillMode, Style, StyleFn}; 2 | 3 | use super::Theme; 4 | 5 | impl Catalog for Theme { 6 | type Class<'a> = StyleFn<'a, Self>; 7 | 8 | fn default<'a>() -> Self::Class<'a> { 9 | Box::new(primary) 10 | } 11 | 12 | fn style(&self, class: &Self::Class<'_>) -> Style { 13 | class(self) 14 | } 15 | } 16 | 17 | pub fn primary(theme: &Theme) -> Style { 18 | Style { 19 | color: theme.colors().general.horizontal_rule, 20 | width: 1, 21 | radius: 0.0.into(), 22 | fill_mode: FillMode::Full, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/appearance/theme/scrollable.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::container; 2 | use iced::widget::scrollable::{ 3 | Catalog, Rail, Scroller, Status, Style, StyleFn, 4 | }; 5 | use iced::{Background, Border, Color, Shadow}; 6 | 7 | use super::Theme; 8 | 9 | impl Catalog for Theme { 10 | type Class<'a> = StyleFn<'a, Self>; 11 | 12 | fn default<'a>() -> Self::Class<'a> { 13 | Box::new(primary) 14 | } 15 | 16 | fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { 17 | class(self, status) 18 | } 19 | } 20 | 21 | pub fn primary(theme: &Theme, status: Status) -> Style { 22 | let rail = Rail { 23 | background: None, 24 | border: Border::default(), 25 | scroller: Scroller { 26 | color: theme.colors().general.horizontal_rule, 27 | border: Border { 28 | radius: 8.0.into(), 29 | width: 0.0, 30 | color: Color::TRANSPARENT, 31 | }, 32 | }, 33 | }; 34 | 35 | match status { 36 | Status::Active { .. } 37 | | Status::Hovered { .. } 38 | | Status::Dragged { .. } => Style { 39 | container: container::Style { 40 | text_color: None, 41 | background: None, 42 | border: Border { 43 | radius: 8.0.into(), 44 | width: 1.0, 45 | color: Color::TRANSPARENT, 46 | }, 47 | shadow: Shadow::default(), 48 | }, 49 | vertical_rail: rail, 50 | horizontal_rail: rail, 51 | gap: None, 52 | }, 53 | } 54 | } 55 | 56 | pub fn hidden(_theme: &Theme, status: Status) -> Style { 57 | let rail = Rail { 58 | background: None, 59 | border: Border::default(), 60 | scroller: Scroller { 61 | color: Color::TRANSPARENT, 62 | border: Border { 63 | radius: 0.0.into(), 64 | width: 0.0, 65 | color: Color::TRANSPARENT, 66 | }, 67 | }, 68 | }; 69 | 70 | match status { 71 | Status::Active { .. } 72 | | Status::Hovered { .. } 73 | | Status::Dragged { .. } => Style { 74 | container: container::Style { 75 | text_color: None, 76 | background: Some(Background::Color(Color::TRANSPARENT)), 77 | border: Border { 78 | radius: 8.0.into(), 79 | width: 1.0, 80 | color: Color::TRANSPARENT, 81 | }, 82 | shadow: Shadow::default(), 83 | }, 84 | vertical_rail: rail, 85 | horizontal_rail: rail, 86 | gap: None, 87 | }, 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/appearance/theme/text.rs: -------------------------------------------------------------------------------- 1 | use data::appearance::theme::{ 2 | alpha_color, alpha_color_calculate, randomize_color, 3 | }; 4 | use data::config::buffer::away; 5 | use iced::widget::text::{Catalog, Style, StyleFn}; 6 | 7 | use super::Theme; 8 | 9 | impl Catalog for Theme { 10 | type Class<'a> = StyleFn<'a, Self>; 11 | 12 | fn default<'a>() -> Self::Class<'a> { 13 | Box::new(none) 14 | } 15 | 16 | fn style(&self, class: &Self::Class<'_>) -> Style { 17 | class(self) 18 | } 19 | } 20 | 21 | pub fn none(_theme: &Theme) -> Style { 22 | Style { color: None } 23 | } 24 | 25 | pub fn primary(theme: &Theme) -> Style { 26 | Style { 27 | color: Some(theme.colors().text.primary), 28 | } 29 | } 30 | 31 | pub fn secondary(theme: &Theme) -> Style { 32 | Style { 33 | color: Some(theme.colors().text.secondary), 34 | } 35 | } 36 | 37 | pub fn tertiary(theme: &Theme) -> Style { 38 | Style { 39 | color: Some(theme.colors().text.tertiary), 40 | } 41 | } 42 | 43 | pub fn error(theme: &Theme) -> Style { 44 | Style { 45 | color: Some(theme.colors().text.error), 46 | } 47 | } 48 | 49 | pub fn success(theme: &Theme) -> Style { 50 | Style { 51 | color: Some(theme.colors().text.success), 52 | } 53 | } 54 | 55 | pub fn action(theme: &Theme) -> Style { 56 | Style { 57 | color: Some(theme.colors().buffer.action), 58 | } 59 | } 60 | 61 | pub fn timestamp(theme: &Theme) -> Style { 62 | Style { 63 | color: Some(theme.colors().buffer.timestamp), 64 | } 65 | } 66 | 67 | pub fn topic(theme: &Theme) -> Style { 68 | Style { 69 | color: Some(theme.colors().buffer.topic), 70 | } 71 | } 72 | 73 | pub fn buffer_title_bar(theme: &Theme) -> Style { 74 | Style { 75 | color: Some(theme.colors().buffer.topic), 76 | } 77 | } 78 | 79 | pub fn unread_indicator(theme: &Theme) -> Style { 80 | Style { 81 | color: Some(theme.colors().general.unread_indicator), 82 | } 83 | } 84 | 85 | pub fn url(theme: &Theme) -> Style { 86 | Style { 87 | color: Some(theme.colors().buffer.url), 88 | } 89 | } 90 | 91 | pub fn nickname>( 92 | theme: &Theme, 93 | seed: Option, 94 | away_appearance: Option, 95 | ) -> Style { 96 | let nickname = theme.colors().buffer.nickname; 97 | let calculate_alpha_color = |color| { 98 | if let Some(away::Appearance::Dimmed(alpha)) = away_appearance { 99 | match alpha { 100 | // Calculate alpha based on background and foreground. 101 | None => alpha_color_calculate( 102 | 0.20, 103 | 0.61, 104 | theme.colors().buffer.background, 105 | color, 106 | ), 107 | // Calculate alpha based on user defined alpha value. 108 | Some(a) => alpha_color(color, a), 109 | } 110 | } else { 111 | color 112 | } 113 | }; 114 | 115 | // If we have a seed we randomize the color based on the seed before adding any alpha value. 116 | let color = match seed { 117 | Some(seed) => { 118 | calculate_alpha_color(randomize_color(nickname, seed.as_ref())) 119 | } 120 | None => calculate_alpha_color(nickname), 121 | }; 122 | 123 | Style { color: Some(color) } 124 | } 125 | -------------------------------------------------------------------------------- /src/appearance/theme/text_input.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::text_input::{Catalog, Status, Style, StyleFn}; 2 | use iced::{Background, Border, Color}; 3 | 4 | use super::Theme; 5 | 6 | impl Catalog for Theme { 7 | type Class<'a> = StyleFn<'a, Self>; 8 | 9 | fn default<'a>() -> Self::Class<'a> { 10 | Box::new(primary) 11 | } 12 | 13 | fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { 14 | class(self, status) 15 | } 16 | } 17 | 18 | pub fn primary(theme: &Theme, status: Status) -> Style { 19 | let active = Style { 20 | background: Background::Color( 21 | theme.colors().buffer.background_text_input, 22 | ), 23 | border: Border { 24 | radius: 4.0.into(), 25 | width: 0.0, 26 | color: Color::TRANSPARENT, 27 | // XXX Not currently displayed in application. 28 | }, 29 | icon: theme.colors().text.primary, 30 | placeholder: theme.colors().text.secondary, 31 | value: theme.colors().text.primary, 32 | selection: theme.colors().buffer.selection, 33 | }; 34 | 35 | match status { 36 | Status::Active | Status::Hovered | Status::Focused { .. } => active, 37 | Status::Disabled => Style { 38 | background: Background::Color( 39 | theme.colors().buffer.background_text_input, 40 | ), 41 | placeholder: Color { 42 | a: 0.2, 43 | ..theme.colors().text.secondary 44 | }, 45 | border: Border { 46 | radius: 4.0.into(), 47 | width: 0.0, 48 | color: Color::TRANSPARENT, 49 | // XXX Not currently displayed in application. 50 | }, 51 | ..active 52 | }, 53 | } 54 | } 55 | 56 | pub fn error(theme: &Theme, status: Status) -> Style { 57 | let primary = primary(theme, status); 58 | 59 | match status { 60 | Status::Active | Status::Hovered | Status::Focused { .. } => Style { 61 | border: Border { 62 | radius: 4.0.into(), 63 | width: 1.0, 64 | color: theme.colors().text.error, 65 | }, 66 | ..primary 67 | }, 68 | Status::Disabled => primary, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/audio.rs: -------------------------------------------------------------------------------- 1 | use std::io::Cursor; 2 | use std::sync::Arc; 3 | use std::thread; 4 | 5 | use data::audio::Sound; 6 | use rodio::{Decoder, OutputStream, Sink}; 7 | 8 | pub fn play(sound: Sound) { 9 | thread::spawn(move || { 10 | if let Err(e) = _play(sound) { 11 | log::error!("Failed to play sound: {e}"); 12 | } 13 | }); 14 | } 15 | 16 | fn _play(sound: Sound) -> Result<(), PlayError> { 17 | let (_stream, stream_handle) = OutputStream::try_default()?; 18 | 19 | let sink = Sink::try_new(&stream_handle)?; 20 | 21 | let source = Decoder::new(Cursor::new(sound))?; 22 | 23 | sink.append(source); 24 | 25 | sink.sleep_until_end(); 26 | 27 | Ok(()) 28 | } 29 | 30 | #[derive(Debug, thiserror::Error)] 31 | pub enum PlayError { 32 | #[error(transparent)] 33 | Decoding(Arc), 34 | #[error(transparent)] 35 | Playing(Arc), 36 | #[error(transparent)] 37 | StreamInitialization(Arc), 38 | } 39 | 40 | impl From for PlayError { 41 | fn from(error: rodio::decoder::DecoderError) -> Self { 42 | Self::Decoding(Arc::new(error)) 43 | } 44 | } 45 | 46 | impl From for PlayError { 47 | fn from(error: rodio::PlayError) -> Self { 48 | Self::Playing(Arc::new(error)) 49 | } 50 | } 51 | 52 | impl From for PlayError { 53 | fn from(error: rodio::StreamError) -> Self { 54 | Self::StreamInitialization(Arc::new(error)) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/buffer/empty.rs: -------------------------------------------------------------------------------- 1 | use data::Config; 2 | use iced::widget::{column, container, text}; 3 | use iced::{Length, alignment}; 4 | 5 | use crate::screen::dashboard::sidebar; 6 | use crate::widget::Element; 7 | 8 | pub fn view<'a, Message: 'a>( 9 | config: &'a Config, 10 | sidebar: &'a sidebar::Sidebar, 11 | ) -> Element<'a, Message> { 12 | let arrow = if sidebar.hidden { 13 | ' ' 14 | } else { 15 | match config.sidebar.position { 16 | data::config::sidebar::Position::Left => '⟵', 17 | data::config::sidebar::Position::Right => '⟶', 18 | data::config::sidebar::Position::Top => '↑', 19 | data::config::sidebar::Position::Bottom => '↓', 20 | } 21 | }; 22 | 23 | let content = column![] 24 | .push( 25 | text(format!("{arrow} select buffer")) 26 | .shaping(text::Shaping::Advanced), 27 | ) 28 | .align_x(iced::Alignment::Center); 29 | 30 | container(content) 31 | .align_x(alignment::Horizontal::Center) 32 | .align_y(alignment::Vertical::Center) 33 | .width(Length::Fill) 34 | .height(Length::Fill) 35 | .into() 36 | } 37 | -------------------------------------------------------------------------------- /src/buffer/input_view/format_tooltip.txt: -------------------------------------------------------------------------------- 1 | Markdown: 2 | _italic_ 3 | __bold__ 4 | ___italic & bold___ 5 | `code` 6 | ||spoiler|| 7 | ~~strikethrough~~ 8 | 9 | Toggles: 10 | $b - bold 11 | $i - italic 12 | $m - monospace 13 | $s - strikethrough 14 | $u - underline 15 | $r - reset 16 | 17 | Color: 18 | $c0 - fg only 19 | $c0,1 - fg & bg 20 | $c - end color 21 | 22 | Color Codes: 23 | 0 - 99 24 | red, lightblue, etc 25 | -------------------------------------------------------------------------------- /src/event.rs: -------------------------------------------------------------------------------- 1 | use iced::{Subscription, event, keyboard, mouse, window}; 2 | 3 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 4 | pub enum Event { 5 | Copy, 6 | Escape, 7 | LeftClick, 8 | } 9 | 10 | pub fn events() -> Subscription<(window::Id, Event)> { 11 | event::listen_with(filtered_events) 12 | } 13 | 14 | fn filtered_events( 15 | event: iced::Event, 16 | status: iced::event::Status, 17 | window: window::Id, 18 | ) -> Option<(window::Id, Event)> { 19 | let ignored = |status| matches!(status, iced::event::Status::Ignored); 20 | 21 | let event = match &event { 22 | iced::Event::Keyboard(keyboard::Event::KeyPressed { 23 | key: keyboard::Key::Named(keyboard::key::Named::Escape), 24 | .. 25 | }) => Some(Event::Escape), 26 | iced::Event::Keyboard(keyboard::Event::KeyPressed { 27 | key: keyboard::Key::Character(c), 28 | modifiers, 29 | .. 30 | }) if c.as_str() == "c" && modifiers.command() => Some(Event::Copy), 31 | iced::Event::Mouse(mouse::Event::ButtonPressed( 32 | mouse::Button::Left, 33 | )) if ignored(status) => Some(Event::LeftClick), 34 | _ => None, 35 | }; 36 | 37 | event.map(|event| (window, event)) 38 | } 39 | -------------------------------------------------------------------------------- /src/icon.rs: -------------------------------------------------------------------------------- 1 | use iced::widget::text; 2 | use iced::widget::text::LineHeight; 3 | 4 | use crate::widget::Text; 5 | use crate::{font, theme}; 6 | 7 | pub fn dot<'a>() -> Text<'a> { 8 | to_text('\u{F111}') 9 | } 10 | 11 | pub fn error<'a>() -> Text<'a> { 12 | to_text('\u{E80D}') 13 | } 14 | 15 | pub fn connected<'a>() -> Text<'a> { 16 | to_text('\u{E800}') 17 | } 18 | 19 | pub fn cancel<'a>() -> Text<'a> { 20 | to_text('\u{E80F}') 21 | } 22 | 23 | pub fn maximize<'a>() -> Text<'a> { 24 | to_text('\u{E801}') 25 | } 26 | 27 | pub fn restore<'a>() -> Text<'a> { 28 | to_text('\u{E805}') 29 | } 30 | 31 | pub fn people<'a>() -> Text<'a> { 32 | to_text('\u{E804}') 33 | } 34 | 35 | pub fn topic<'a>() -> Text<'a> { 36 | to_text('\u{E803}') 37 | } 38 | 39 | pub fn search<'a>() -> Text<'a> { 40 | to_text('\u{E808}') 41 | } 42 | 43 | pub fn checkmark<'a>() -> Text<'a> { 44 | to_text('\u{E806}') 45 | } 46 | 47 | pub fn file_transfer<'a>() -> Text<'a> { 48 | to_text('\u{E802}') 49 | } 50 | 51 | pub fn refresh<'a>() -> Text<'a> { 52 | to_text('\u{E807}') 53 | } 54 | 55 | pub fn megaphone<'a>() -> Text<'a> { 56 | to_text('\u{E809}') 57 | } 58 | 59 | pub fn theme_editor<'a>() -> Text<'a> { 60 | to_text('\u{E80A}') 61 | } 62 | 63 | pub fn undo<'a>() -> Text<'a> { 64 | to_text('\u{E80B}') 65 | } 66 | 67 | pub fn copy<'a>() -> Text<'a> { 68 | to_text('\u{F0C5}') 69 | } 70 | 71 | pub fn popout<'a>() -> Text<'a> { 72 | to_text('\u{E80E}') 73 | } 74 | 75 | pub fn logs<'a>() -> Text<'a> { 76 | to_text('\u{E810}') 77 | } 78 | 79 | pub fn menu<'a>() -> Text<'a> { 80 | to_text('\u{F0C9}') 81 | } 82 | 83 | pub fn documentation<'a>() -> Text<'a> { 84 | to_text('\u{E812}') 85 | } 86 | 87 | pub fn highlights<'a>() -> Text<'a> { 88 | to_text('\u{E811}') 89 | } 90 | 91 | pub fn scroll_to_bottom<'a>() -> Text<'a> { 92 | to_text('\u{F103}') 93 | } 94 | 95 | pub fn share<'a>() -> Text<'a> { 96 | to_text('\u{E813}') 97 | } 98 | 99 | pub fn mark_as_read<'a>() -> Text<'a> { 100 | to_text('\u{E817}') 101 | } 102 | 103 | pub fn config<'a>() -> Text<'a> { 104 | to_text('\u{F1C9}') 105 | } 106 | 107 | fn to_text<'a>(unicode: char) -> Text<'a> { 108 | text(unicode.to_string()) 109 | .line_height(LineHeight::Relative(1.0)) 110 | .size(theme::ICON_SIZE) 111 | .font(font::ICON) 112 | } 113 | -------------------------------------------------------------------------------- /src/logger.rs: -------------------------------------------------------------------------------- 1 | use std::sync::mpsc; 2 | use std::time::{Duration, Instant}; 3 | use std::{env, mem, thread}; 4 | 5 | use chrono::Utc; 6 | pub use data::log::{Error, Record}; 7 | use log::Log; 8 | use tokio::sync::mpsc as tokio_mpsc; 9 | use tokio_stream::wrappers::ReceiverStream; 10 | 11 | pub fn setup(is_debug: bool) -> Result>, Error> { 12 | let level_filter = env::var("RUST_LOG") 13 | .ok() 14 | .as_deref() 15 | .map(str::parse::) 16 | .transpose()? 17 | .unwrap_or(log::Level::Debug) 18 | .to_level_filter(); 19 | 20 | let mut io_sink = fern::Dispatch::new().format(|out, message, record| { 21 | out.finish(format_args!( 22 | "{}:{} -- {}", 23 | chrono::Local::now().format("%H:%M:%S%.3f"), 24 | record.level(), 25 | message 26 | )); 27 | }); 28 | 29 | if is_debug || cfg!(feature = "dev") { 30 | io_sink = io_sink.chain(std::io::stdout()); 31 | } else { 32 | let log_file = data::log::file()?; 33 | 34 | io_sink = io_sink.chain(log_file); 35 | } 36 | 37 | let (channel_sink, receiver) = channel_logger(); 38 | 39 | fern::Dispatch::new() 40 | .level(log::LevelFilter::Off) 41 | .level_for("panic", log::LevelFilter::Error) 42 | .level_for("iced_wgpu", log::LevelFilter::Info) 43 | .level_for("data", level_filter) 44 | .level_for("halloy", level_filter) 45 | .chain(io_sink) 46 | .chain(channel_sink) 47 | .apply()?; 48 | 49 | Ok(receiver) 50 | } 51 | 52 | fn channel_logger() -> (Box, ReceiverStream>) { 53 | struct Sink { 54 | sender: mpsc::Sender, 55 | } 56 | 57 | impl Log for Sink { 58 | fn enabled(&self, _metadata: &::log::Metadata) -> bool { 59 | true 60 | } 61 | 62 | fn log(&self, record: &::log::Record) { 63 | let _ = self.sender.send(Record { 64 | timestamp: Utc::now(), 65 | level: record.level().into(), 66 | message: format!("{}", record.args()), 67 | }); 68 | } 69 | 70 | fn flush(&self) {} 71 | } 72 | 73 | let (log_sender, log_receiver) = mpsc::channel(); 74 | let (async_sender, async_receiver) = tokio_mpsc::channel(1); 75 | 76 | thread::spawn(move || { 77 | const BATCH_SIZE: usize = 25; 78 | const BATCH_TIMEOUT: Duration = Duration::from_millis(250); 79 | 80 | let mut batch = Vec::with_capacity(BATCH_SIZE); 81 | let mut timeout = Instant::now(); 82 | 83 | loop { 84 | if let Ok(log) = log_receiver.recv_timeout(BATCH_TIMEOUT) { 85 | batch.push(log); 86 | } 87 | 88 | if batch.len() >= BATCH_SIZE 89 | || (!batch.is_empty() && timeout.elapsed() >= BATCH_TIMEOUT) 90 | { 91 | timeout = Instant::now(); 92 | 93 | let _ = async_sender.blocking_send(mem::replace( 94 | &mut batch, 95 | Vec::with_capacity(BATCH_SIZE), 96 | )); 97 | } 98 | } 99 | }); 100 | 101 | ( 102 | Box::new(Sink { sender: log_sender }), 103 | ReceiverStream::new(async_receiver), 104 | ) 105 | } 106 | -------------------------------------------------------------------------------- /src/modal/connect_to_server.rs: -------------------------------------------------------------------------------- 1 | use data::config; 2 | use iced::widget::{button, checkbox, column, container, text}; 3 | use iced::{Length, alignment}; 4 | 5 | use super::Message; 6 | use crate::theme; 7 | use crate::widget::Element; 8 | 9 | pub fn view<'a>(raw: &'a str, config: &config::Server) -> Element<'a, Message> { 10 | container( 11 | column![ 12 | text("Connect to server?"), 13 | text(raw).style(theme::text::tertiary), 14 | ] 15 | .push( 16 | checkbox( 17 | "Accept invalid certificates", 18 | config.dangerously_accept_invalid_certs, 19 | ) 20 | .on_toggle(|toggle| { 21 | Message::ServerConnect( 22 | super::ServerConnect::DangerouslyAcceptInvalidCerts(toggle), 23 | ) 24 | }), 25 | ) 26 | .push( 27 | column![ 28 | button( 29 | container(text("Accept")) 30 | .align_x(alignment::Horizontal::Center) 31 | .width(Length::Fill), 32 | ) 33 | .padding(5) 34 | .width(Length::Fixed(250.0)) 35 | .style(|theme, status| theme::button::secondary( 36 | theme, status, false 37 | )) 38 | .on_press(Message::ServerConnect( 39 | super::ServerConnect::AcceptNewServer 40 | )), 41 | button( 42 | container(text("Close")) 43 | .align_x(alignment::Horizontal::Center) 44 | .width(Length::Fill), 45 | ) 46 | .padding(5) 47 | .width(Length::Fixed(250.0)) 48 | .style(|theme, status| theme::button::secondary( 49 | theme, status, false 50 | )) 51 | .on_press(Message::Cancel), 52 | ] 53 | .spacing(4), 54 | ) 55 | .spacing(20) 56 | .align_x(iced::Alignment::Center), 57 | ) 58 | .width(Length::Shrink) 59 | .style(theme::container::tooltip) 60 | .padding(25) 61 | .into() 62 | } 63 | -------------------------------------------------------------------------------- /src/modal/prompt_before_open_url.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | Length, alignment, 3 | widget::{button, column, container, text, vertical_space}, 4 | }; 5 | 6 | use super::Message; 7 | use crate::{theme, widget::Element}; 8 | 9 | pub fn view(payload: &str) -> Element { 10 | container( 11 | column![ 12 | column![ 13 | text("This hyperlink will take you to"), 14 | text(payload) 15 | .style(theme::text::url) 16 | .wrapping(text::Wrapping::Glyph) 17 | .width(Length::Shrink), 18 | vertical_space().height(8), 19 | text("Are you sure you want to go there?"), 20 | ] 21 | .align_x(iced::Alignment::Center) 22 | .spacing(2), 23 | column![ 24 | button( 25 | container(text("Open URL")) 26 | .align_x(alignment::Horizontal::Center) 27 | .width(Length::Fill), 28 | ) 29 | .padding(5) 30 | .width(Length::Fixed(250.0)) 31 | .style(|theme, status| theme::button::secondary( 32 | theme, status, false 33 | )) 34 | .on_press(Message::OpenURL(payload.to_string())), 35 | button( 36 | container(text("Close")) 37 | .align_x(alignment::Horizontal::Center) 38 | .width(Length::Fill), 39 | ) 40 | .padding(5) 41 | .width(Length::Fixed(250.0)) 42 | .style(|theme, status| theme::button::secondary( 43 | theme, status, false 44 | )) 45 | .on_press(Message::Cancel), 46 | ] 47 | .spacing(4), 48 | ] 49 | .spacing(20) 50 | .align_x(iced::Alignment::Center), 51 | ) 52 | .max_width(400) 53 | .width(Length::Shrink) 54 | .style(theme::container::tooltip) 55 | .padding(25) 56 | .into() 57 | } 58 | -------------------------------------------------------------------------------- /src/modal/reload_configuration_error.rs: -------------------------------------------------------------------------------- 1 | use data::config; 2 | use iced::widget::{button, column, container, text}; 3 | use iced::{Length, alignment}; 4 | 5 | use super::Message; 6 | use crate::theme; 7 | use crate::widget::Element; 8 | 9 | pub fn view<'a>(error: &config::Error) -> Element<'a, Message> { 10 | container( 11 | column![ 12 | text("Error reloading configuration file"), 13 | text(error.to_string()).style(theme::text::error), 14 | button( 15 | container(text("Close")) 16 | .align_x(alignment::Horizontal::Center) 17 | .width(Length::Fill), 18 | ) 19 | .style(|theme, status| theme::button::secondary( 20 | theme, status, false 21 | )) 22 | .padding(5) 23 | .width(Length::Fixed(250.0)) 24 | .on_press(Message::Cancel) 25 | ] 26 | .spacing(20) 27 | .align_x(iced::Alignment::Center), 28 | ) 29 | .width(Length::Shrink) 30 | .style(theme::container::error_tooltip) 31 | .padding(25) 32 | .into() 33 | } 34 | -------------------------------------------------------------------------------- /src/notification/toast.rs: -------------------------------------------------------------------------------- 1 | #[cfg(target_os = "macos")] 2 | pub fn prepare() { 3 | match notify_rust::set_application(data::environment::APPLICATION_ID) { 4 | Ok(()) => {} 5 | Err(error) => { 6 | log::error!("{}", error.to_string()); 7 | } 8 | } 9 | } 10 | 11 | #[cfg(not(target_os = "macos"))] 12 | pub fn prepare() {} 13 | 14 | pub fn show(title: &str, body: impl ToString) { 15 | let mut notification = notify_rust::Notification::new(); 16 | 17 | notification.summary(title); 18 | notification.body(&body.to_string()); 19 | 20 | #[cfg(target_os = "linux")] 21 | { 22 | notification.appname("Halloy"); 23 | notification.icon(data::environment::APPLICATION_ID); 24 | } 25 | #[cfg(target_os = "windows")] 26 | { 27 | notification.app_id(data::environment::APPLICATION_ID); 28 | } 29 | 30 | let _ = notification.show(); 31 | } 32 | -------------------------------------------------------------------------------- /src/screen.rs: -------------------------------------------------------------------------------- 1 | pub mod dashboard; 2 | pub mod help; 3 | pub mod migration; 4 | pub mod welcome; 5 | 6 | pub use dashboard::Dashboard; 7 | pub use help::Help; 8 | pub use migration::Migration; 9 | pub use welcome::Welcome; 10 | -------------------------------------------------------------------------------- /src/screen/help.rs: -------------------------------------------------------------------------------- 1 | use data::environment::WIKI_WEBSITE; 2 | use data::{Config, config}; 3 | use iced::widget::{button, column, container, text, vertical_space}; 4 | use iced::{Length, alignment}; 5 | 6 | use crate::widget::Element; 7 | use crate::{icon, theme}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum Message { 11 | RefreshConfiguration, 12 | OpenConfigurationDirectory, 13 | OpenWikiWebsite, 14 | } 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum Event { 18 | RefreshConfiguration, 19 | } 20 | 21 | #[derive(Debug, Clone)] 22 | pub struct Help { 23 | error: config::Error, 24 | } 25 | 26 | impl Help { 27 | pub fn new(error: config::Error) -> Self { 28 | Help { error } 29 | } 30 | 31 | pub fn update(&mut self, message: Message) -> Option { 32 | match message { 33 | Message::RefreshConfiguration => Some(Event::RefreshConfiguration), 34 | Message::OpenConfigurationDirectory => { 35 | let _ = open::that_detached(Config::config_dir()); 36 | 37 | None 38 | } 39 | Message::OpenWikiWebsite => { 40 | let _ = open::that_detached(WIKI_WEBSITE); 41 | 42 | None 43 | } 44 | } 45 | } 46 | 47 | pub fn view<'a>(&self) -> Element<'a, Message> { 48 | let config_button = button( 49 | container(text("Open Config Directory")) 50 | .align_x(alignment::Horizontal::Center) 51 | .width(Length::Fill), 52 | ) 53 | .padding(5) 54 | .width(Length::Fixed(250.0)) 55 | .style(|theme, status| theme::button::secondary(theme, status, false)) 56 | .on_press(Message::OpenConfigurationDirectory); 57 | 58 | let wiki_button = button( 59 | container(text("Open Wiki Website")) 60 | .align_x(alignment::Horizontal::Center) 61 | .width(Length::Fill), 62 | ) 63 | .padding(5) 64 | .width(Length::Fill) 65 | .style(|theme, status| theme::button::secondary(theme, status, false)) 66 | .on_press(Message::OpenWikiWebsite); 67 | 68 | let refresh_button = button( 69 | container(text("Refresh Halloy")) 70 | .align_x(alignment::Horizontal::Center) 71 | .width(Length::Fill), 72 | ) 73 | .padding(5) 74 | .width(Length::Fixed(250.0)) 75 | .style(|theme, status| theme::button::secondary(theme, status, false)) 76 | .on_press(Message::RefreshConfiguration); 77 | 78 | let content = column![] 79 | .push(icon::error().style(theme::text::error).size(35)) 80 | .push(vertical_space().height(10)) 81 | .push(text("Error reading configuration file")) 82 | .push(vertical_space().height(10)) 83 | .push(text(self.error.to_string()).style(theme::text::error)) 84 | .push(vertical_space().height(10)) 85 | .push( 86 | column![] 87 | .width(250) 88 | .spacing(4) 89 | .push(config_button) 90 | .push(wiki_button) 91 | .push(refresh_button), 92 | ) 93 | .align_x(iced::Alignment::Center); 94 | 95 | container(content) 96 | .align_x(alignment::Horizontal::Center) 97 | .align_y(alignment::Vertical::Center) 98 | .width(Length::Fill) 99 | .height(Length::Fill) 100 | .into() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/screen/migration.rs: -------------------------------------------------------------------------------- 1 | use data::Config; 2 | use data::environment::MIGRATION_WEBSITE; 3 | use iced::widget::{button, column, container, text, vertical_space}; 4 | use iced::{Length, alignment}; 5 | 6 | use crate::widget::Element; 7 | use crate::{font, theme}; 8 | 9 | #[derive(Debug, Clone)] 10 | pub enum Message { 11 | RefreshConfiguration, 12 | OpenConfigurationDirectory, 13 | OpenMigrationWebsite, 14 | } 15 | 16 | #[derive(Debug, Clone)] 17 | pub enum Event { 18 | RefreshConfiguration, 19 | } 20 | 21 | #[derive(Debug, Default, Clone)] 22 | pub struct Migration; 23 | 24 | impl Migration { 25 | pub fn new() -> Self { 26 | // Create template config file. 27 | Config::create_initial_config(); 28 | 29 | Migration 30 | } 31 | 32 | pub fn update(&mut self, message: Message) -> Option { 33 | match message { 34 | Message::RefreshConfiguration => Some(Event::RefreshConfiguration), 35 | Message::OpenConfigurationDirectory => { 36 | let _ = open::that_detached(Config::config_dir()); 37 | 38 | None 39 | } 40 | Message::OpenMigrationWebsite => { 41 | let _ = open::that_detached(MIGRATION_WEBSITE); 42 | 43 | None 44 | } 45 | } 46 | } 47 | 48 | pub fn view<'a>(&self) -> Element<'a, Message> { 49 | let config_button = button( 50 | container(text("Open Config Directory")) 51 | .align_x(alignment::Horizontal::Center) 52 | .width(Length::Fill), 53 | ) 54 | .padding(5) 55 | .width(Length::Fill) 56 | .style(|theme, status| theme::button::secondary(theme, status, false)) 57 | .on_press(Message::OpenConfigurationDirectory); 58 | 59 | let wiki_button = button( 60 | container(text("Open Migration Guide")) 61 | .align_x(alignment::Horizontal::Center) 62 | .width(Length::Fill), 63 | ) 64 | .padding(5) 65 | .width(Length::Fill) 66 | .style(|theme, status| theme::button::secondary(theme, status, false)) 67 | .on_press(Message::OpenMigrationWebsite); 68 | 69 | let refresh_button = button( 70 | container(text("Refresh Halloy")) 71 | .align_x(alignment::Horizontal::Center) 72 | .width(Length::Fill), 73 | ) 74 | .padding(5) 75 | .width(Length::Fill) 76 | .style(|theme, status| theme::button::secondary(theme, status, false)) 77 | .on_press(Message::RefreshConfiguration); 78 | 79 | let content = column![] 80 | .spacing(1) 81 | .push(vertical_space().height(10)) 82 | .push(text("Your configuration file is outdated :(").font(font::MONO_BOLD.clone())) 83 | .push(vertical_space().height(4)) 84 | .push(text( 85 | "Halloy recently switched configuration file format from YAML to TOML. This was done in an effort to make it easier to work with as a user.", 86 | )) 87 | .push(vertical_space().height(8)) 88 | .push(text("To migrate your configuration file, please visit the migration guide below.")) 89 | .push(vertical_space().height(10)) 90 | .push( 91 | column![] 92 | .width(250) 93 | .spacing(4) 94 | .push(config_button) 95 | .push(wiki_button) 96 | .push(refresh_button), 97 | ) 98 | .width(350) 99 | .align_x(iced::Alignment::Center); 100 | 101 | container(content) 102 | .align_x(alignment::Horizontal::Center) 103 | .align_y(alignment::Vertical::Center) 104 | .width(Length::Fill) 105 | .height(Length::Fill) 106 | .into() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/stream.rs: -------------------------------------------------------------------------------- 1 | use std::hash::Hash; 2 | 3 | pub use data::stream::{self, *}; 4 | use data::{config, server}; 5 | use futures::Stream; 6 | use iced::Subscription; 7 | 8 | pub fn run( 9 | entry: server::Entry, 10 | proxy: Option, 11 | ) -> Subscription { 12 | struct State { 13 | entry: server::Entry, 14 | proxy: Option, 15 | } 16 | 17 | impl State { 18 | fn run(&self) -> impl Stream + use<> { 19 | stream::run(self.entry.clone(), self.proxy.clone()) 20 | } 21 | } 22 | 23 | impl PartialEq for State { 24 | fn eq(&self, other: &Self) -> bool { 25 | self.entry.server.eq(&other.entry.server) 26 | } 27 | } 28 | 29 | impl Hash for State { 30 | fn hash(&self, state: &mut H) { 31 | self.entry.server.hash(state); 32 | } 33 | } 34 | 35 | Subscription::run_with(State { entry, proxy }, State::run) 36 | } 37 | -------------------------------------------------------------------------------- /src/url.rs: -------------------------------------------------------------------------------- 1 | use futures::stream::BoxStream; 2 | use iced::advanced::subscription::{self, Hasher}; 3 | use iced::{self, Subscription}; 4 | 5 | #[cfg(target_os = "macos")] 6 | pub fn listen() -> Subscription { 7 | use futures::stream::StreamExt; 8 | use iced::advanced::graphics::futures::subscription::{ 9 | Event, MacOS, PlatformSpecific, 10 | }; 11 | 12 | struct OnUrl; 13 | 14 | impl subscription::Recipe for OnUrl { 15 | type Output = String; 16 | 17 | fn hash(&self, state: &mut Hasher) { 18 | use std::hash::Hash; 19 | 20 | struct Marker; 21 | std::any::TypeId::of::().hash(state); 22 | } 23 | 24 | fn stream( 25 | self: Box, 26 | input: subscription::EventStream, 27 | ) -> BoxStream<'static, Self::Output> { 28 | input 29 | .filter_map(move |event| { 30 | if let Event::Interaction { status, .. } = &event { 31 | if *status == iced::event::Status::Captured { 32 | return futures::future::ready(None); 33 | } 34 | } 35 | 36 | let result = match event { 37 | Event::PlatformSpecific(event) => match event { 38 | PlatformSpecific::MacOS(macos) => match macos { 39 | MacOS::ReceivedUrl(url) => Some(url), 40 | }, 41 | }, 42 | _ => None, 43 | }; 44 | 45 | futures::future::ready(result) 46 | }) 47 | .boxed() 48 | } 49 | } 50 | 51 | subscription::from_recipe(OnUrl) 52 | } 53 | 54 | #[cfg(not(target_os = "macos"))] 55 | pub fn listen() -> Subscription { 56 | struct Listener; 57 | 58 | impl subscription::Recipe for Listener { 59 | type Output = String; 60 | 61 | fn hash(&self, state: &mut Hasher) { 62 | use std::hash::Hash; 63 | 64 | struct Marker; 65 | std::any::TypeId::of::().hash(state); 66 | } 67 | 68 | fn stream( 69 | self: Box, 70 | _input: subscription::EventStream, 71 | ) -> BoxStream<'static, Self::Output> { 72 | ipc::listen() 73 | } 74 | } 75 | 76 | subscription::from_recipe(Listener) 77 | } 78 | -------------------------------------------------------------------------------- /src/widget.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | use iced::advanced::text; 3 | 4 | pub use self::anchored_overlay::anchored_overlay; 5 | pub use self::color_picker::color_picker; 6 | pub use self::combo_box::combo_box; 7 | pub use self::context_menu::context_menu; 8 | pub use self::decorate::decorate; 9 | pub use self::double_pass::double_pass; 10 | pub use self::key_press::key_press; 11 | pub use self::message_content::message_content; 12 | pub use self::modal::modal; 13 | pub use self::notify_visibility::notify_visibility; 14 | pub use self::selectable_rich_text::selectable_rich_text; 15 | pub use self::selectable_text::selectable_text; 16 | pub use self::shortcut::shortcut; 17 | pub use self::tooltip::tooltip; 18 | use crate::Theme; 19 | 20 | pub mod anchored_overlay; 21 | pub mod collection; 22 | pub mod color_picker; 23 | pub mod combo_box; 24 | pub mod context_menu; 25 | pub mod decorate; 26 | pub mod double_click; 27 | pub mod double_pass; 28 | pub mod key_press; 29 | pub mod message_content; 30 | pub mod modal; 31 | pub mod notify_visibility; 32 | pub mod selectable_rich_text; 33 | pub mod selectable_text; 34 | pub mod shortcut; 35 | pub mod tooltip; 36 | 37 | pub type Renderer = iced::Renderer; 38 | pub type Element<'a, Message> = iced::Element<'a, Message, Theme, Renderer>; 39 | pub type Content<'a, Message> = 40 | iced::widget::pane_grid::Content<'a, Message, Theme, Renderer>; 41 | pub type TitleBar<'a, Message> = 42 | iced::widget::pane_grid::TitleBar<'a, Message, Theme, Renderer>; 43 | pub type Column<'a, Message> = 44 | iced::widget::Column<'a, Message, Theme, Renderer>; 45 | pub type Row<'a, Message> = iced::widget::Row<'a, Message, Theme, Renderer>; 46 | pub type Text<'a> = iced::widget::Text<'a, Theme, Renderer>; 47 | pub type Container<'a, Message> = 48 | iced::widget::Container<'a, Message, Theme, Renderer>; 49 | pub type Button<'a, Message> = iced::widget::Button<'a, Message, Theme>; 50 | 51 | pub fn message_marker<'a, M: 'a>( 52 | width: Option, 53 | style: impl Fn(&Theme) -> selectable_text::Style + 'a, 54 | ) -> Element<'a, M> { 55 | let marker = selectable_text(MESSAGE_MARKER_TEXT); 56 | 57 | if let Some(width) = width { 58 | marker.width(width).align_x(text::Alignment::Right) 59 | } else { 60 | marker 61 | } 62 | .style(style) 63 | .into() 64 | } 65 | 66 | pub const MESSAGE_MARKER_TEXT: &str = " ∙"; 67 | 68 | pub mod button { 69 | use super::Element; 70 | use crate::appearance::theme; 71 | 72 | /// Transparent button which simply makes the given content 73 | /// into a clickable button without additional styling. 74 | pub fn transparent_button<'a, Message>( 75 | content: impl Into>, 76 | message: Message, 77 | ) -> Element<'a, Message> 78 | where 79 | Message: Clone + 'a, 80 | { 81 | iced::widget::button(content) 82 | .padding(0) 83 | .style(theme::button::bare) 84 | .on_press(message) 85 | .into() 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/widget/collection.rs: -------------------------------------------------------------------------------- 1 | use iced::Element; 2 | use iced::widget::{Column, Row}; 3 | 4 | pub trait Collection<'a, Message, Theme>: Sized { 5 | fn push(self, element: impl Into>) -> Self; 6 | 7 | fn push_maybe( 8 | self, 9 | element: Option>>, 10 | ) -> Self { 11 | match element { 12 | Some(element) => self.push(element), 13 | None => self, 14 | } 15 | } 16 | } 17 | 18 | impl<'a, Message, Theme> Collection<'a, Message, Theme> 19 | for Column<'a, Message, Theme> 20 | { 21 | fn push(self, element: impl Into>) -> Self { 22 | Self::push(self, element) 23 | } 24 | } 25 | 26 | impl<'a, Message, Theme> Collection<'a, Message, Theme> 27 | for Row<'a, Message, Theme> 28 | { 29 | fn push(self, element: impl Into>) -> Self { 30 | Self::push(self, element) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/widget/double_click.rs: -------------------------------------------------------------------------------- 1 | use std::time; 2 | 3 | use iced::advanced::widget::Tree; 4 | use iced::advanced::{Clipboard, Layout, Shell, mouse}; 5 | use iced::event; 6 | 7 | const TIMEOUT_MILLIS: u64 = 250; 8 | 9 | use crate::Element; 10 | use crate::widget::{Renderer, decorate}; 11 | 12 | pub fn double_click<'a, Message>( 13 | content: impl Into>, 14 | message: Message, 15 | ) -> Element<'a, Message> 16 | where 17 | Message: Clone + 'a, 18 | { 19 | decorate(content) 20 | .update( 21 | move |state: &mut Internal, 22 | inner: &mut Element<'a, Message>, 23 | tree: &mut Tree, 24 | event: &iced::Event, 25 | layout: Layout<'_>, 26 | cursor: mouse::Cursor, 27 | renderer: &Renderer, 28 | clipboard: &mut dyn Clipboard, 29 | shell: &mut Shell<'_, Message>, 30 | viewport: &iced::Rectangle| { 31 | inner.as_widget_mut().update( 32 | tree, event, layout, cursor, renderer, clipboard, shell, 33 | viewport, 34 | ); 35 | 36 | if shell.is_event_captured() { 37 | return; 38 | } 39 | 40 | if !cursor.is_over(layout.bounds()) { 41 | return; 42 | } 43 | 44 | let event::Event::Mouse(mouse::Event::ButtonPressed( 45 | mouse::Button::Left, 46 | )) = event 47 | else { 48 | return; 49 | }; 50 | 51 | let now = time::Instant::now(); 52 | let timeout = time::Duration::from_millis(TIMEOUT_MILLIS); 53 | let diff = now - state.instant; 54 | 55 | if diff <= timeout { 56 | shell.publish(message.clone()); 57 | shell.capture_event(); 58 | } else { 59 | state.instant = time::Instant::now(); 60 | } 61 | }, 62 | ) 63 | .into() 64 | } 65 | 66 | #[derive(Clone, Debug)] 67 | struct Internal { 68 | instant: time::Instant, 69 | } 70 | 71 | impl Default for Internal { 72 | fn default() -> Self { 73 | Internal { 74 | instant: time::Instant::now(), 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/widget/double_pass.rs: -------------------------------------------------------------------------------- 1 | //! A widget that uses a two pass layout. 2 | //! 3 | //! Layout from first pass is used as the limits for the second pass 4 | 5 | use iced::advanced::{self, layout, widget}; 6 | use iced::{Element, Size}; 7 | 8 | use super::decorate; 9 | 10 | /// Layout from first pass is used as the limits for the second pass 11 | pub fn double_pass<'a, Message, Theme, Renderer>( 12 | first_pass: impl Into>, 13 | second_pass: impl Into>, 14 | ) -> Element<'a, Message, Theme, Renderer> 15 | where 16 | Message: 'a, 17 | Theme: 'a, 18 | Renderer: advanced::Renderer + 'a, 19 | { 20 | decorate(second_pass) 21 | .layout(Layout { 22 | first_pass: first_pass.into(), 23 | }) 24 | .into() 25 | } 26 | 27 | struct Layout<'a, Message, Theme, Renderer> { 28 | first_pass: Element<'a, Message, Theme, Renderer>, 29 | } 30 | 31 | impl<'a, Message, Theme, Renderer> 32 | decorate::Layout<'a, Message, Theme, Renderer, ()> 33 | for Layout<'a, Message, Theme, Renderer> 34 | where 35 | Message: 'a, 36 | Theme: 'a, 37 | Renderer: advanced::Renderer + 'a, 38 | { 39 | fn layout( 40 | &self, 41 | _state: &mut (), 42 | second_pass: &iced::Element<'a, Message, Theme, Renderer>, 43 | tree: &mut iced::advanced::widget::Tree, 44 | renderer: &Renderer, 45 | limits: &iced::advanced::layout::Limits, 46 | ) -> layout::Node { 47 | let layout = self.first_pass.as_widget().layout( 48 | &mut widget::Tree::new(&self.first_pass), 49 | renderer, 50 | limits, 51 | ); 52 | 53 | let new_limits = layout::Limits::new( 54 | Size::ZERO, 55 | layout 56 | .size() 57 | // eliminate float precision issues if second pass 58 | // is fill 59 | .expand(Size::new(horizontal_expansion(), 1.0)), 60 | ); 61 | 62 | second_pass.as_widget().layout(tree, renderer, &new_limits) 63 | } 64 | } 65 | 66 | pub fn horizontal_expansion() -> f32 { 67 | 1.0 68 | } 69 | -------------------------------------------------------------------------------- /src/widget/key_press.rs: -------------------------------------------------------------------------------- 1 | use iced::advanced::{Clipboard, Layout, Shell, widget}; 2 | pub use iced::keyboard::key::Named; 3 | pub use iced::keyboard::{Key, Modifiers}; 4 | use iced::{Event, Rectangle, keyboard, mouse}; 5 | 6 | use super::{Element, Renderer, decorate}; 7 | 8 | pub fn key_press<'a, Message>( 9 | base: impl Into>, 10 | key: Key, 11 | modifiers: Modifiers, 12 | on_press: Message, 13 | ) -> Element<'a, Message> 14 | where 15 | Message: 'a + Clone, 16 | { 17 | decorate(base) 18 | .update( 19 | move |_state: &mut (), 20 | inner: &mut Element<'a, Message>, 21 | tree: &mut widget::Tree, 22 | event: &Event, 23 | layout: Layout<'_>, 24 | cursor: mouse::Cursor, 25 | renderer: &Renderer, 26 | clipboard: &mut dyn Clipboard, 27 | shell: &mut Shell<'_, Message>, 28 | viewport: &Rectangle| { 29 | if let Event::Keyboard(keyboard::Event::KeyPressed { 30 | key: k, 31 | modifiers: m, 32 | .. 33 | }) = &event 34 | { 35 | if key == *k && modifiers == *m { 36 | shell.publish(on_press.clone()); 37 | shell.capture_event(); 38 | return; 39 | } 40 | } 41 | 42 | inner.as_widget_mut().update( 43 | tree, event, layout, cursor, renderer, clipboard, shell, 44 | viewport, 45 | ); 46 | }, 47 | ) 48 | .into() 49 | } 50 | -------------------------------------------------------------------------------- /src/widget/notify_visibility.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | 3 | use iced::advanced::{Clipboard, Layout, Shell, widget}; 4 | use iced::{Event, Padding, Rectangle, mouse, window}; 5 | 6 | use super::{Element, Renderer, decorate}; 7 | 8 | #[derive(Debug, Clone, Copy)] 9 | pub enum When { 10 | Visible, 11 | NotVisible, 12 | } 13 | 14 | pub fn notify_visibility<'a, Message>( 15 | content: impl Into>, 16 | margin: impl Into, 17 | when: When, 18 | message: Message, 19 | ) -> Element<'a, Message> 20 | where 21 | Message: 'a + Clone, 22 | { 23 | let margin = margin.into(); 24 | let sent = RefCell::new(false); 25 | 26 | decorate(content) 27 | .update( 28 | move |_state: &mut (), 29 | inner: &mut Element<'a, Message>, 30 | tree: &mut widget::Tree, 31 | event: &Event, 32 | layout: Layout<'_>, 33 | cursor: mouse::Cursor, 34 | renderer: &Renderer, 35 | clipboard: &mut dyn Clipboard, 36 | shell: &mut Shell<'_, Message>, 37 | viewport: &Rectangle| { 38 | if let Event::Window(window::Event::RedrawRequested(_)) = &event 39 | { 40 | let mut sent = sent.borrow_mut(); 41 | 42 | let is_visible = 43 | viewport.expand(margin).intersects(&layout.bounds()); 44 | 45 | let should_notify = match when { 46 | When::Visible => is_visible, 47 | When::NotVisible => !is_visible, 48 | }; 49 | 50 | if should_notify && !*sent { 51 | shell.publish(message.clone()); 52 | *sent = true; 53 | } 54 | } 55 | 56 | inner.as_widget_mut().update( 57 | tree, event, layout, cursor, renderer, clipboard, shell, 58 | viewport, 59 | ); 60 | }, 61 | ) 62 | .into() 63 | } 64 | -------------------------------------------------------------------------------- /src/widget/selectable_text/selection.rs: -------------------------------------------------------------------------------- 1 | use iced::advanced::text; 2 | use iced::{Point, Rectangle, Vector}; 3 | 4 | use super::Value; 5 | 6 | #[derive(Debug, Clone, Copy, PartialEq)] 7 | pub struct Raw { 8 | pub start: Point, 9 | pub end: Point, 10 | } 11 | 12 | impl Raw { 13 | pub fn resolve(&self, bounds: Rectangle) -> Option { 14 | if f32::max(f32::min(self.start.y, self.end.y), bounds.y) 15 | <= f32::min( 16 | f32::max(self.start.y, self.end.y), 17 | bounds.y + bounds.height, 18 | ) 19 | { 20 | let (mut start, mut end) = if self.start.y < self.end.y 21 | || self.start.y == self.end.y && self.start.x < self.end.x 22 | { 23 | (self.start, self.end) 24 | } else { 25 | (self.end, self.start) 26 | }; 27 | 28 | let clip = |p: Point| Point { 29 | x: p.x.max(bounds.x).min(bounds.x + bounds.width), 30 | y: p.y.max(bounds.y).min(bounds.y + bounds.height), 31 | }; 32 | 33 | if start.y < bounds.y { 34 | start = bounds.position(); 35 | } else { 36 | start = clip(start); 37 | } 38 | 39 | if end.y > bounds.y + bounds.height { 40 | end = bounds.position() + Vector::from(bounds.size()); 41 | } else { 42 | end = clip(end); 43 | } 44 | 45 | ((start.x - end.x).abs() > 1.0).then_some(Resolved { start, end }) 46 | } else { 47 | None 48 | } 49 | } 50 | } 51 | 52 | #[derive(Debug, Clone, Copy)] 53 | pub struct Resolved { 54 | pub start: Point, 55 | pub end: Point, 56 | } 57 | 58 | #[derive(Debug, Clone, Copy)] 59 | pub struct Selection { 60 | pub start: usize, 61 | pub end: usize, 62 | } 63 | 64 | pub fn selection( 65 | raw: Raw, 66 | bounds: Rectangle, 67 | paragraph: &P, 68 | value: &Value, 69 | ) -> Option { 70 | let resolved = raw.resolve(bounds)?; 71 | 72 | let start_pos = relative(resolved.start, bounds); 73 | let end_pos = relative(resolved.end, bounds); 74 | 75 | let start = find_cursor_position(paragraph, value, start_pos)?; 76 | let end = find_cursor_position(paragraph, value, end_pos)?; 77 | 78 | (start != end).then(|| Selection { 79 | start: start.min(end), 80 | end: start.max(end), 81 | }) 82 | } 83 | 84 | pub fn find_cursor_position( 85 | paragraph: &P, 86 | value: &Value, 87 | cursor_position: Point, 88 | ) -> Option { 89 | let value = value.to_string(); 90 | 91 | let char_offset = 92 | paragraph.hit_test(cursor_position).map(text::Hit::cursor)?; 93 | 94 | Some( 95 | unicode_segmentation::UnicodeSegmentation::graphemes( 96 | &value[..char_offset], 97 | true, 98 | ) 99 | .count(), 100 | ) 101 | } 102 | 103 | fn relative(point: Point, bounds: Rectangle) -> Point { 104 | point - Vector::new(bounds.x, bounds.y) 105 | } 106 | -------------------------------------------------------------------------------- /src/widget/shortcut.rs: -------------------------------------------------------------------------------- 1 | use data::shortcut; 2 | pub use data::shortcut::Command; 3 | use iced::advanced::widget::Tree; 4 | use iced::advanced::{Clipboard, Layout, Shell}; 5 | use iced::{Event, keyboard, mouse}; 6 | 7 | use super::{Element, Renderer, decorate}; 8 | 9 | pub fn shortcut<'a, Message>( 10 | base: impl Into>, 11 | shortcuts: Vec, 12 | on_press: impl Fn(Command) -> Message + 'a, 13 | ) -> Element<'a, Message> 14 | where 15 | Message: 'a, 16 | { 17 | decorate(base) 18 | .update( 19 | move |modifiers: &mut shortcut::Modifiers, 20 | inner: &mut Element<'a, Message>, 21 | tree: &mut Tree, 22 | event: &iced::Event, 23 | layout: Layout<'_>, 24 | cursor: mouse::Cursor, 25 | renderer: &Renderer, 26 | clipboard: &mut dyn Clipboard, 27 | shell: &mut Shell<'_, Message>, 28 | viewport: &iced::Rectangle| { 29 | match &event { 30 | Event::Keyboard(keyboard::Event::KeyPressed { 31 | key, 32 | modifiers, 33 | .. 34 | }) => { 35 | let key_bind = 36 | shortcut::KeyBind::from((key.clone(), *modifiers)); 37 | 38 | if let Some(command) = shortcuts 39 | .iter() 40 | .find_map(|shortcut| shortcut.execute(&key_bind)) 41 | { 42 | shell.publish((on_press)(command)); 43 | shell.capture_event(); 44 | return; 45 | } 46 | } 47 | Event::Keyboard(keyboard::Event::ModifiersChanged( 48 | new_modifiers, 49 | )) => { 50 | *modifiers = (*new_modifiers).into(); 51 | } 52 | _ => {} 53 | } 54 | 55 | inner.as_widget_mut().update( 56 | tree, event, layout, cursor, renderer, clipboard, shell, 57 | viewport, 58 | ); 59 | }, 60 | ) 61 | .into() 62 | } 63 | -------------------------------------------------------------------------------- /src/widget/tooltip.rs: -------------------------------------------------------------------------------- 1 | pub use iced::widget::tooltip::Position; 2 | use iced::widget::{container, text}; 3 | 4 | use super::Element; 5 | use crate::theme; 6 | 7 | pub fn tooltip<'a, Message: 'a>( 8 | content: impl Into>, 9 | tooltip: Option<&'a str>, 10 | position: Position, 11 | ) -> Element<'a, Message> { 12 | match tooltip { 13 | Some(tooltip) => iced::widget::tooltip( 14 | content, 15 | container(text(tooltip).style(theme::text::secondary)) 16 | .style(theme::container::tooltip) 17 | .padding(8), 18 | position, 19 | ) 20 | .into(), 21 | None => content.into(), 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /wix/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/wix/banner.png -------------------------------------------------------------------------------- /wix/dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/squidowl/halloy/c8e901d069a99afcc450e8b4a9b7f6aec6376c70/wix/dialog.png --------------------------------------------------------------------------------