Repository: qxb3/fum Branch: main Commit: a74d5f169b1c Files: 64 Total size: 142.8 KB Directory structure: gitextract_3rs7evnw/ ├── .fpm ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── README.md ├── doc-site/ │ ├── .gitignore │ ├── .npmrc │ ├── DOC_VERSION │ ├── README.md │ ├── docs-content/ │ │ ├── 01_getting_started/ │ │ │ └── doc.md │ │ ├── 02_configuring/ │ │ │ └── doc.md │ │ ├── 03_faq/ │ │ │ └── doc.md │ │ ├── 04_compability/ │ │ │ └── doc.md │ │ └── 05_rices/ │ │ └── doc.md │ ├── package.json │ ├── src/ │ │ ├── app.css │ │ ├── app.d.ts │ │ ├── app.html │ │ ├── global.d.ts │ │ ├── lib/ │ │ │ ├── Nav.svelte │ │ │ ├── SideBar.svelte │ │ │ ├── index.ts │ │ │ └── stores.ts │ │ ├── routes/ │ │ │ ├── +layout.js │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ └── docs/ │ │ │ └── [title]/ │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ └── vite-env.d.ts │ ├── svelte.config.js │ ├── tailwind.config.js │ ├── tsconfig.json │ └── vite.config.ts ├── flake.nix ├── nix/ │ ├── default.nix │ └── hm-module.nix └── src/ ├── action.rs ├── cli.rs ├── config/ │ ├── config.rs │ ├── defaults.rs │ ├── keybind.rs │ └── mod.rs ├── fum.rs ├── main.rs ├── meta.rs ├── regexes.rs ├── state.rs ├── text.rs ├── ui.rs ├── utils/ │ ├── align.rs │ ├── mod.rs │ ├── terminal.rs │ └── widget.rs └── widget/ ├── button.rs ├── container.rs ├── cover_art.rs ├── empty.rs ├── label.rs ├── mod.rs ├── progress.rs ├── volume.rs └── widget.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .fpm ================================================ -s dir --name fum --description "A tui-based mpris music client." --license MIT --maintainer "qxb3 " --url "https://github.com/qxb3/fum" --architecture x86_64 --prefix /usr/bin ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v2 - name: Install system dependencies run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config wget libssl-dev squashfs-tools -y build-essential - name: Set up Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - name: Compile fum run: cargo build --release --locked - name: Add postfix to binary run: | cp target/release/fum target/release/fum-x86-64_${{ github.ref_name }} - name: Build .deb package uses: bpicode/github-action-fpm@master with: fpm_args: fum fpm_opts: -t deb --version ${{ github.ref_name }} -p fum-x86-64_${{ github.ref_name }}.deb -C target/release - name: Build .rpm package uses: bpicode/github-action-fpm@master with: fpm_args: fum fpm_opts: -t rpm --version ${{ github.ref_name }} -p fum-x86-64_${{ github.ref_name }}.rpm -C target/release - name: Create GitHub Release uses: softprops/action-gh-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: generate_release_notes: true make_latest: true files: | target/release/fum-x86-64_${{ github.ref_name }} fum-x86-64_${{ github.ref_name }}.deb fum-x86-64_${{ github.ref_name }}.rpm - name: Ensure binary is executable run: chmod +x target/release/fum-x86-64_${{ github.ref_name }} ================================================ FILE: .gitignore ================================================ /target ================================================ FILE: CONTRIBUTING.md ================================================ # Contribution Guide Thank you for your interest in contributing! Please follow these guidelines to keep the project clean and manageable. ## Branching - Always work on the `dev` branch. - Never push directly to `main`; changes should go through `dev` first. - Use feature branches (e.g., `fix-command` or `add-something`) for new additions. ## Commit Rules - Keep commits focused on a single change. - Use meaningful commit messages. - Split up commits for different purposes (e.g., bug fixes, refactoring, new features should be separate commits). ## Pull Requests - Always create PRs against the `dev` branch. - Ensure your changes do not break existing functionality. - Keep PRs focused on a single purpose. --- Happy fumming!! ================================================ FILE: Cargo.toml ================================================ [package] name = "fum-player" description = "A tui-based mpris music client." version = "1.3.1" repository = "https://github.com/qxb3/fum" homepage = "https://github.com/qxb3/fum" license = "MIT" edition = "2021" [[bin]] name = "fum" path = "./src/main.rs" [dependencies] base64 = "0.22.1" clap = { version = "4.5.23", features = ["derive"] } crossterm = "0.28.1" expanduser = "1.2.2" image = "0.25.5" lazy_static = "1.5.0" mpris = "2.0.1" ratatui = { version = "0.29.0", features = ["all-widgets", "serde"] } ratatui-image = { version = "4.1.0", features = ["crossterm"] } regex = "1.11.1" reqwest = { version = "0.12.9", features = ["blocking"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" unicode-width = "0.2.0" uuid = { version = "1.12.0", features = ["v4", "fast-rng"] } [profile.release] opt-level = 3 lto = "fat" codegen-units = 1 panic = "abort" strip = true ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 qxb3 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

fum: A fully customizable tui-based mpris music client.

fum is a tui-based mpris music client designed to provide a simple and efficient way to display and control your music within a tui interface.

# ❗❗ IMPORTANT ❗❗ > ⚠️ **Currently in a full codebase rewrite** > See [#98](https://github.com/qxb3/fum/issues/98) for the motivations on why. Though you can still use fum as it is.

## Demo ## Installation [![Packaging status](https://repology.org/badge/vertical-allrepos/fum.svg)](https://repology.org/project/fum/versions) ## Build from source ```bash git clone https://github.com/qxb3/fum.git cd fum cargo build --release # Either copy/move `target/release/yum` to /usr/bin # Or add the release path to your system's path # Moving fum binary to /usr/bin mv target/release/fum /usr/bin ``` ### Configuring See [Wiki](https://github.com/qxb3/fum/wiki/Configuring) ### Need help? Join [Discord Server!](https://discord.gg/UfXMeyZ6Zt). ## Showcase on a rice ## Contributing [CONTRIBUTING](https://github.com/qxb3/fum/blob/main/CONTRIBUTING.md) ## LICENSE [MIT](https://github.com/qxb3/fum/blob/main/LICENSE) ================================================ FILE: doc-site/.gitignore ================================================ node_modules # Output .output .vercel .netlify .wrangler /.svelte-kit /build # OS .DS_Store Thumbs.db # Env .env .env.* !.env.example !.env.test # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* ================================================ FILE: doc-site/.npmrc ================================================ engine-strict=true ================================================ FILE: doc-site/DOC_VERSION ================================================ 1.3.0 ================================================ FILE: doc-site/README.md ================================================ # docs-site Documentation site for fum. ================================================ FILE: doc-site/docs-content/01_getting_started/doc.md ================================================ --- title: Getting Started next: /docs/configuring:Configuring --- # Getting Started --- ## Installation To get started with fum. You will need to install fum to your system.
[![Packaging status](https://repology.org/badge/vertical-allrepos/fum.svg)](https://repology.org/project/fum/versions)
### Arch ```bash yay -S fum-bin # paru -S fum-bin ``` ### Nix (Home Manager) ```nix { pkgs, ... }: { home.packages = with pkgs; [ fum ]; } ``` ```bash home-manager switch ``` ### Debian based systems Download the latest deb from [releases](https://github.com/qxb3/fum/releases) first and: ```bash sudo dpkg -i fum-x86-64_v{DOC_VERSION}.deb ``` ### RPM based systems Download the latest rpm from [releases](https://github.com/qxb3/fum/releases) first and: ```bash sudo dnf install fum-x86-64_v{DOC_VERSION}.rpm ``` ### Build from source ```bash git clone https://github.com/qxb3/fum.git cd fum cargo build --release # Either copy/move `target/release/yum` to /usr/bin # Or add the release path to your system's path # Moving fum binary to /usr/bin mv target/release/fum /usr/bin ``` ## Usage NOTE: if it says "No Music" and you don't use spotify (default), See Below. ```bash fum ``` ### Detecting other music players. If you use different music player pass it on fum to detect: ```bash fum --players player_identity_name,other.player.bus_name # Seperated by comma and can use identity name or bus name. ``` ### List Players To check what the music player's *identity* or *bus* name is, use: ```bash fum list-players Active Players: * spotify ~> org.mpris.MediaPlayer2.spotify ``` ### Compatibility Some terminals will have some issues of rendering the image as those don't support an image protocol yet. See [Compatibility](https://github.com/benjajaja/ratatui-image#compatibility-matrix) For compatible terminals. ================================================ FILE: doc-site/docs-content/02_configuring/doc.md ================================================ --- title: Configuring prev: /docs/getting_started:Getting Started next: /docs/faq:FAQ --- # Configuring This entire section will be covering how to configure fum. --- ## Overview Fum's configuration is located on `$HOME/.config/fum/config.jsonc`. This config file will be containing all of fum's functionality, look, and behavior. Also take a note that whatever you put in here will be overwritten by the cli. So for example you have `spotify` in the `players` in the config but have `mpd` in the cli, `mpd` will be the one in used. Also if you don't know know `jsonc` its just a normal json with comments support. --- ### Example Config ```jsonc { "players": ["spotify", "lollypop", "org.mpris.MediaPlayer2.mpv"], // List of music players to be detected. "use_active_player": false, // Whether to detect other active mpris player. "fps": 10, // Fps of fum. "keybinds": { // Keybinds. "esc;q": "quit()", // escape or q key to quit. "h": "prev()", // h key to previous music. "l": "next()", // l key to next music. " ": "play_pause()" // space key to play or pause music. }, "align": "center", // Where in the terminal should fum be placed. "direction": "vertical", // The parent direction of layout. "flex": "start", // The parent flex of layout. "width": 20, // The width allocated. "height": 18, // The height allocated. "border": false, // Whether to render a border around. "padding": [0, 0], // Whether to add horizontal,vertical padding. "bg": "reset", // The parent background color. "fg": "reset", // The parent foreground color. "cover_art_ascii": "~/.config/fum/ascii.txt", // The ascii art or text to be displayed in place of cover-art if it doesn't exists. "layout": [] // Where we define our ui layout. } ``` --- ### Config Properties #### * `players` (Optional) \- List of player names that will be detected by fum. This can be the name or the bus_name of the player. Note that identity is case insensitive and bus_name are not.
- Type: `string[]` - Default: `["spotify"]` #### * `use_active_player` (Optional) \- Whether to use the most likely active player when there it can't find players on the players array.
- Type: `boolean` - Default: `false` #### * `keybinds` (Optional) \- Keybinds to do [#Actions](#actions). Keybinds are defined into `key` and `action`. The dropdown below is the list of available keys you can use
Keys List - `backspace` - `enter` - `left` - `left arrow key` - `right` - `right arrow key` - `down` - `down arrow key` - `up` - `up arrow key` - `end` - `page_up` - `page_down` - `tab` - `back_tab` - `shift + tab key` - `delete` - `insert` - `caps` - `capslock key` - `esc` - `f1` to `f12` - `a-z, A-Z` - `alphabetical characters` - `{}[]|;:'"`,.<>/?` - `keyboard symbols`

- Type: `Object` - Default: ```jsonc { "esc;q": "quit()", "h": "prev()", "l": "next()", " ": "play_pause()" } ``` #### * `align` (Optional) \- Where in the terminal fum will be positioned in.
- Type: `string` - Values: - `left` - `top` - `right` - `bottom` - `center` - `top-left` - `top-right` - `bottom-left` - `bottom-right` - Default: `center` #### * `direction` (Optional) \- See [#Direction](#direction).
- Type: `string` - Default: `horizontal` #### * `flex` (Optional) \- See [#ContainerFlex](#containerflex).
- Type: `string` - Default: `start` #### * `width` (Optional) \- The allocated width.
- Type: `number` - Default: `19` #### * `height` (Optional) \- The allocated height.
- Type: `number` - Default: `15` #### * `border` (Optional) \- Whether to render a border around.
- Type: `boolean` - Default: `false` #### * `padding` (Optional) \- Whether to add horizontal,vertical padding.
- Type: `[number, number]` - Default: `[0, 0]` #### * `cover_art_ascii` (Optional) \- The ascii art or text to be displayed in place of cover-art if it doesn't exists or there is no current music.
- Type: `string` - Default: `""` #### * `layout` (Optional) \- Layout ui to be rendered. See [#Widgets](#widgets) For all the available widgets.
- Type: `widget[]` - Default: [#Example Full Config](#example-full-config) --- ### Widgets List of available widgets. #### `Container` \- A container containing widgets.
- Fields: - `width`: `number` (optional). Specifies the width of the container. See [#Width & Height](#width--height). - `height`: `number` (optional). Specifies the height of the container. See [#Width & Height](#width--height). - `border`: `boolean` (Optional). Whether to draw a border around the widget. Default: `false` - `padding`: `[number, number]` (Optional). Whether to add padding on the container. Default: `[0, 0]` - `direction`: `string` (Optional). Specifies the layout direction of child widgets. See [#Direction](#direction). - `flex`: `string` (Optional). Specifies how space is distributed among child widgets. See [#ContainerFlex](#containerflex). - `bg`: `string` (Optional). The background color of this container area. See [#Bg & Fg](#bg--fg). - `fg`: `string` (Optional). The foreground color of the children. See [#Bg & Fg](#bg--fg). - `children`: `widget[]` (Required). The childrens of the container.
- Example: ```jsonc { "type": "container", "width": 20, "height": 20, "border": false, "padding": [0, 0], "direction": "vertical", "flex": "start", "bg": "blue", "fg": "black", "children": [ { "type": "empty", "size": 1 } ] } ``` #### `CoverArt` \- Displays music cover art.
- Fields: - `width`: `number` (optional). Specifies the width of the container. See [#Width & Height](#width--height). - `height`: `number` (optional). Specifies the height of the container. See [#Width & Height](#width--height). - `border`: `boolean` (Optional). Whether to draw a border around the widget. Default: `false` - `resize`: `string` (Optional). Specifies which resize method to use. - Values: - `fit` - If the width or height is smaller than the area, the image will be resized maintaining proportions. - `crop` - If the width or height is smaller than the area, the image will be cropped. - `scale` - Scale the image. - Default: `scale` - `bg`: `string` (Optional). The background color of this container area. See [#Bg & Fg](#bg--fg). - `fg`: `string` (Optional). The foreground color of the children. See [#Bg & Fg](#bg--fg).
- Example: ```jsonc { "type": "cover-art", "width": 20, "height": 20, "border": false, "resize": "scale", "bg": "red", "fg": "#000000" } ``` #### `Label` \- Displays a text label.
- Fields: - `text`: `string` (Required). The text to display in the label. See [#Text](#text). - `align`: `string` (Optional). Specifies the alignment of the text. See [#LabelAlignment](#labelalignment). - `truncate`: `boolean` (Optional). Specifies whether to truncate the text if it exceeds the available space. - Default: `true` - `bold`: `boolean` (Optional). Makes the label text bold. . - Default: `false` - `bg`: `string` (Optional). The background color of the label. See [#Bg & Fg](#bg--fg). - `fg`: `string` (Optional). The foreground color of the label. See [#Bg & Fg](#bg--fg).
- Example: ```jsonc { "type": "label", "text": "$title", "align": "center", "truncate": true, "bold": false, "bg": "black", "fg": "white" } ``` #### `Button` \- Very similar on [#Label](#label) in terms of display but this one is interactable.
- Fields: - `text`: `string` (Required). The text to display in the button. See [#Text](#text). - `action`: `string` (Optional). Specifies an action to perform when the button is clicked. See [#Actions](#actions). - `exec`: `string` (Optional). Spawns a shell command to execute when the button is clicked (Note that this will quietly execute and will not notify you if it errors). - `bold`: `boolean` (Optional). Makes the label text bold. . - Default: `false` - `bg`: `string` (Optional). The background color of the button. See [#Bg & Fg](#bg--fg). - `fg`: `string` (Optional). The foreground color of the button. See [#Bg & Fg](#bg--fg).
- Example: ```jsonc { "type": "button", "text": "$status_icon", "action": "play_pause()", "exec": "echo hi", "bold": false, "bg": "reset", "fg": "magenta" } ``` #### `Progress` \- Displays a progress bar.
- Fields: - `size`: `number` (optional). Specifies the width of the progress bar. See [#Width & Height](#width--height). - `direction`: `string` (Optional). Whether to display the progress bar horizontally or vertically. See [#Direction](#direction). - `progress`: string (Required). - `char`: The character used to represent the progress portion of the progress bar. - `bg`: The background color of the progress. See [#Bg & Fg](#bg--fg). - `fg`: The foreground color of the progress. See [#Bg & Fg](#bg--fg). - `empty`: string (Required). - `char`: The character used to represent the empty portion of the progress bar. - `bg`: The background color of the empty. See [#Bg & Fg](#bg--fg). - `fg`: The foreground color of the empty. See [#Bg & Fg](#bg--fg).
- Example: ```jsonc { "type": "progress", "size": 10, "direction": "horizontal" "progress": { "char": "", "fg": "red", "bg": "blue" }, "empty": { "char": "-", "fg": "blue", "bg": "red" } } ``` #### `Volume` \- Displays a volume bar.
- Fields: - `size`: `number` (optional). Specifies the width of the volume bar. See [#Width & Height](#width--height). - `direction`: `string` (Optional). Whether to display the volume bar horizontally or vertically. See [#Direction](#direction). - `volume`: string (Required). - `char`: The character used to represent the volume portion of the volume bar. - `bg`: The background color of the volume. See [#Bg & Fg](#bg--fg). - `fg`: The foreground color of the volume. See [#Bg & Fg](#bg--fg). - `empty`: string (Required). - `char`: The character used to represent the empty portion of the volume bar. - `bg`: The background color of the empty. See [#Bg & Fg](#bg--fg). - `fg`: The foreground color of the empty. See [#Bg & Fg](#bg--fg).
- Example: ```jsonc { "type": "volume", "size": 10, "direction": "vertical", "volume": { "char": "/", "fg": "red", "bg": "blue" }, "empty": { "char": " ", "fg": "blue", "bg": "red" } } ``` #### `Empty` \- Displays an empty area. Useful for spacing.
- Fields: - `size`: `number` (optional). Specifies the width of the empty space. See [#Width & Height](#width--height).
- Example: ```jsonc { "type": "empty", "size": 1 } ``` --- ### Widget Properties List of widget properties that will be used on widget fields. #### Width & Height \- width and height are often optional properties. When not defined, the widget will automatically fill the remaining available space. #### Direction \- This property specifies the layout direction of the component.
- Values: - `horizontal` - `vertical` - Default: `horizontal` #### LabelAlignment \- Specifies the alignment of text within a label.
- Values: - `left` - `center` - `right` Default: `left` #### ContainerFlex \- Defines how space is distributed among items in a container.
- Values: - `start` - `center` - `end` - `space-around` - `space-between` - Default: `start` #### Bg & Fg Variants: - `reset` - The default color. - `black` - `white` - `green` / `lightgreen` - `yellow` / `lightyellow` - `blue` / `lightblue` - `red` / `lightred` - `magenta` / `lightmagenta` - `cyan` / `lightcyan` - `gray` / `darkgray` - `rgb` - An RGB color. Example: `"fg": {"Rgb": [255, 0, 255]}` - `indexed` - An 8-bit 256 color. Example {"fg": {"Indexed":10}} - Default: `reset` #### Text - Available variables: - `$title` - Title of the music. - `$artists` - Artists of the music. - `$album` - Album name of the music. - `$status-icon` - A single ascii icon that represents the status / playback state. - `$status-text` - Similar to $status-icon but in text format instead of nerdfonts icon. - `$position` - The current position / progress of the music. - `$position-ext` - Same as $position but prepended 0 at the start. - `$length` - The total length of the music. - `$length-ext` - Same as $length but prepended 0 at the start. - `$remaining-length` - The remaining length of the music. - `$remaining-length-ext` - Same as $remaining-length but prepended 0 at the start. - `$volume` - The current player volume (0 - 100).
- Text functions: - `get_meta(key: string)` - Get a specific metadata that is not available in the variables above. - `var($foo, $length)` - Define a mutable variable, where $foo is the variable name & $length is the variable value. You can use toggle() & set() actions to mutate it. See [#Actions](#actions). For those actions. #### Actions - Available actions: - `quit()` - Quits fum. - `stop()` - Stops the player. - `play()` - Play the music. - `pause()` - Pause the music. - `prev()` - Back the music. - `play_pause()` - Play / Pause the music. - `next()` - Skips the music. - `shuffle_off()` - Turn off the shuffle. - `shuffle_toggle()` - Toggles the shuffle on / off. - `shuffle_on()` - Turn on the shuffle. - `loop_none()` - Set the loop to none. - `loop_playlist()` - Set the loop to playlist. - `loop_track()` - Set the loop to track. - `loop_cycle()` - Cycle loop: none -> playlist -> track -> none. - `forward(2500)` - Fast forward the music 2500 milliseconds. - `backward(2500)` - Step backward the music 2500 milliseconds. - `forward(-1)` - If -1 used in forward(-1) it will go to the end of the track. - `backward(-1)` - If -1 used in backward(-1) it will go to the start of the track. - `volume(+10)` - Increases the volume +10. - `volume(-10)` - Decreases the volume -10. - `volume(50)` - Sets the volume to 50 (0 - 100). - `toggle($foo, $length, $remaining-length)` - Toggles the value of a variable, where $foo is the name and $length, $remaining-length is the values that will be toggled. - `set($foo, $title)` - Set the value of a variable, where $foo is the name and $title is the value to set. --- ### Example Full Config ```jsonc { "players": ["spotify", "lollypop", "org.mpris.MediaPlayer2.mpv"], "use_active_player": false, "debug": false, "width": 20, "height": 18, "layout": [ { "type": "cover-art" }, { "type": "container", "direction": "vertical", "children": [ { "type": "label", "text": "$title", "align": "center" }, { "type": "label", "text": "$artists", "align": "center" }, { "type": "empty", "size": 1 }, { "type": "container", "height": 1, "flex": "space-around", "children": [ { "type": "button", "text": "󰒮", "action": "prev()" }, { "type": "button", "text": "$status-icon", "action": "play_pause()" }, { "type": "button", "text": "󰒭", "action": "next()" } ] }, { "type": "empty", "size": 1 }, { "type": "progress", "progress": { "char": "󰝤" }, "empty": { "char": "󰁱" } }, { "type": "container", "height": 1, "flex": "space-between", "children": [ { "type": "label", "text": "$position", "align": "left" }, { "type": "label", "text": "$length", "align": "right" } ] } ] } ] } ``` ================================================ FILE: doc-site/docs-content/03_faq/doc.md ================================================ --- title: FAQ prev: /docs/configuring:Configuring next: /docs/compability:Compability --- # FAQ --- ## Why is there a delay in updating/changing the music? > Two things: your internet & cpu. Everytime the song/music has been updated fum will fetch/download the image so depending on your internet speed this might take a while. after the image has been fetched fum will also decode the image to properly render the image from your terminal and this decoding is done thru your cpu. ## Why is there a slight or huge cpu spike whenever music is updated/changed? > As stated in the answer above fum will also require to decode the image to properly render the image, And this decoding part is expensive. ================================================ FILE: doc-site/docs-content/04_compability/doc.md ================================================ --- title: Compability prev: /docs/faq:FAQ next: /docs/rices:Rices --- # Compability --- Some terminals will have some issues of rendering the image as those don't support an image protocol yet. See [Compability](https://github.com/benjajaja/ratatui-image?tab=readme-ov-file#compatibility-matrix) For compatible terminals. ================================================ FILE: doc-site/docs-content/05_rices/doc.md ================================================ --- title: Rices prev: /docs/compability:Compability --- # Rices Compilation of rices / customization of fum. --- ### * danielwerg - lowfi clone ![preview](/rices/danielwerg_lowfi_clone.png)
Layout Config NOTE: Volume bar is never show ([#68](https://github.com/qxb3/fum/issues/68)) ```jsonc { "players": ["lowfi"], "keybinds": { "s;S": "next()", "n;N": "next()", "p;P": "play_pause()", "-;_;down": "volume(-5)", "left": "volume(-1)", "+;=;up": "volume(+5)", "right": "volume(+1)", "q;Q;ctrl+c": "quit()" }, "width": 31, "height": 5, "border": true, "padding": [2, 1], "layout": [ { "type": "container", "direction": "vertical", "children": [ { "type": "container", "children": [ { "type": "container", "width": 7, "children": [ { "type": "button", "text": "$status-text", "action": "play_pause()" } ] }, { "type": "empty", "size": 1 }, { "type": "label", "text": "$title", "bold": true } ] }, { "type": "container", "children": [ { "type": "empty", "size": 1 }, { "type": "container", "children": [ { "type": "button", "text": "[" }, { "type": "progress", "progress": { "char": "/" }, "empty": { "char": " " } }, { "type": "button", "text": "]" } ] }, { "type": "empty", "size": 1 }, { "type": "container", "width": 11, "children": [ { "type": "button", "text": "$position-ext" }, { "type": "button", "text": "/" }, { "type": "button", "text": "var($length-style, $length-ext)", "action": "toggle($length-style, $length-ext, $remaining-length-ext)" } ] } ] }, { "type": "container", "children": [ { "type": "empty", "size": 1 }, { "type": "container", "width": 7, "children": [{ "type": "label", "text": "volume:" }] }, { "type": "empty", "size": 1 }, { "type": "container", "children": [ { "type": "button", "text": "[" }, { "type": "volume", "volume": { "char": "/" }, "empty": { "char": " " } }, { "type": "button", "text": "]" } ] }, { "type": "empty", "size": 1 }, { "type": "container", "width": 4, "children": [{ "type": "label", "text": "$volume%" }] } ] }, { "type": "container", "flex": "space-between", "children": [ // { "type": "button", "text": "[s]kip", "action": "next()" }, // { "type": "button", "text": "[p]ause", "action": "play_pause()" }, // { "type": "button", "text": "[q]uit", "action": "quit()" } { "type": "container", "width": 6, "children": [ { "type": "button", "text": "[s]", "action": "next()", "bold": true }, { "type": "button", "text": "kip", "action": "next()" } ] }, { "type": "container", "width": 7, "children": [ { "type": "button", "text": "[p]", "action": "play_pause()", "bold": true }, { "type": "button", "text": "ause", "action": "play_pause()" } ] }, { "type": "container", "width": 6, "children": [ { "type": "button", "text": "[q]", "action": "quit()", "bold": true }, { "type": "button", "text": "uit", "action": "quit()" } ] } ] } ] } ] } ```
### * qxb3 ![preview](/rices/preconfig_06.png)
Layout Config ```jsonc { "players": ["spotify"], "debug": false, "keybinds": { "esc;q": "quit()", "h": "prev()", "l": "next()", " ": "play_pause()", "-": "volume(-5)", "+": "volume(+5)", "left": "backward(2500)", "right": "forward(2500)" }, "width": 33, "height": 16, "direction": "vertical", "layout": [ { "type": "container", "width": 33, "height": 11, "children": [ { "type": "label", "text": "$title", "direction": "vertical", "fg": "green", "bold": true }, { "type": "empty", "size": 2 }, { "type": "cover-art" } ] }, { "type": "container", "height": 4, "direction": "vertical", "children": [ { "type": "container", "children": [ { "type": "empty", "size": 3 }, { "type": "button", "text": "P: " }, { "type": "button", "text": "[" }, { "type": "progress", "progress": { "char": "=" }, "empty": { "char": " " } }, { "type": "button", "text": "]" } ] }, { "type": "container", "children": [ { "type": "empty", "size": 3 }, { "type": "button", "text": "V: " }, { "type": "button", "text": "[" }, { "type": "volume", "volume": { "char": "=" }, "empty": { "char": " " } }, { "type": "button", "text": "]" } ] }, { "type": "container", "children": [ { "type": "empty", "size": 3 }, { "type": "button", "text": "[󰒮 prev]" }, { "type": "button", "text": "[$status-icon play/pause]" }, { "type": "button", "text": "[󰒭 next]" } ] } ] } ] } ```
### * qxb3 ![preview](/rices/preconfig_05.png)
Layout Config ```jsonc { "players": ["spotify"], "width": 40, "height": 14, "layout": [ { "type": "container", "height": 3, "direction": "vertical", "children": [ { "type": "label", "text": "==[ $title ]==", "align": "center", "bold": true, "fg": "yellow" }, { "type": "progress", "progress": { "char": "=", "fg": "green" }, "empty": { "char": "-", "fg": "gray" } } ] }, { "type": "empty", "size": 1 }, { "type": "container", "children": [ { "type": "cover-art" }, { "type": "button", "direction": "vertical", "text": "prev", "action": "prev()" }, { "type": "empty", "size": 2 }, { "type": "button", "direction": "vertical", "text": "$status-text", "action": "play_pause()" }, { "type": "empty", "size": 2 }, { "type": "button", "direction": "vertical", "text": "next", "action": "next()" } ] } ] } ```
### * qxb3 ![preview](/rices/preconfig_04.png)
Layout Config ```jsonc { "players": ["spotify", "mpv"], "use_active_player": true, "width": 30, "height": 5, "layout": [ { "type": "label", "text": "$title", "align": "center", "bold": true }, { "type": "label", "text": "$artists", "align": "center" }, { "type": "empty", "size": 1 }, { "type": "container", "flex": "space-between", "children": [ { "type": "button", "text": "prev", "action": "prev()" }, { "type": "button", "text": "play/pause", "action": "play_pause()" }, { "type": "button", "text": "next", "action": "next()" } ] }, { "type": "progress", "progress": { "char": "■", "fg": "white" }, "empty": { "char": "□", "fg": "gray" } } ] } ```
### * qxb3 ![preview](/rices/preconfig_03.png)
Layout Config ```jsonc { "players": ["spotify"], "debug": false, "keybinds": { "esc;q": "quit()", "h": "prev()", "l": "next()", " ": "play_pause()", "-": "volume(-5)", "+": "volume(+5)", "left": "backward(2500)", "right": "forward(2500)" }, "width": 21, "height": 15, "direction": "vertical", "layout": [ { "type": "label", "text": "> $title <", "align": "center" }, { "type": "label", "text": "> $artists <", "align": "center" }, { "type": "empty", "size": 1 }, { "type": "cover-art" }, { "type": "empty", "size": 1 }, { "type": "container", "height": 1, "flex": "space-around", "children": [ { "type": "button", "text": "󰒝", "action": "shuffle_toggle()" }, { "type": "button", "text": "󰒮", "action": "prev()" }, { "type": "button", "text": "$status-icon", "action": "play_pause()" }, { "type": "button", "text": "󰒭", "action": "next()" }, { "type": "button", "text": "󰑐", "action": "loop_track()" } ] }, { "type": "empty", "size": 1 }, { "type": "progress", "progress": { "char": ">" }, "empty": { "char": "<" } } ] } ```
### * qxb3 ![preview](/rices/preconfig_02.png)
Layout Config ```jsonc { "players": ["spotify"], "debug": false, "keybinds": { "esc;q": "quit()", "h": "prev()", "l": "next()", " ": "play_pause()", "-": "volume(-5)", "+": "volume(+5)", "left": "backward(2500)", "right": "forward(2500)" }, "width": 43, "height": 8, "direction": "horizontal", "layout": [ { "type": "cover-art" }, { "type": "empty", "size": 2 }, { "type": "container", "direction": "vertical", "children": [ { "type": "label", "text": "󰝚 $title" }, { "type": "label", "text": "󰠃 $artists" }, { "type": "label", "text": "󰓎 get_meta(xesam:autoRating)" }, { "type": "label", "text": " get_meta(xesam:discNumber)" }, { "type": "container", "children": [] }, { "type": "container", "height": 1, "children": [ { "type": "button", "text": "󰒮", "action": "prev()" }, { "type": "empty", "size": 3 }, { "type": "button", "text": "$status-icon", "action": "play_pause()" }, { "type": "empty", "size": 3 }, { "type": "button", "text": "󰒭", "action": "next()" } ] }, { "type": "progress", "progress": { "char": "" }, "empty": { "char": "-" } }, { "type": "container", "flex": "space-between", "height": 1, "children": [ { "type": "button", "text": "$position" }, { "type": "button", "text": "var($len-style, $length)", "action": "toggle($len-style, $length, $remaining-length)" } ] } ] } ] } ```
### * qxb3 ![preview](/rices/preconfig_01.png)
Layout Config ```jsonc { "players": ["spotify"], "debug": false, "keybinds": { "esc;q": "quit()", "h": "prev()", "l": "next()", " ": "play_pause()", "-": "volume(-5)", "+": "volume(+5)", "left": "backward(2500)", "right": "forward(2500)" }, "width": 22, "height": 10, "layout": [ { "type": "container", "direction": "horizontal", "children": [ { "type": "container", "direction": "vertical", "width": 20, "children": [ { "type": "label", "text": "$title" }, { "type": "cover-art" }, { "type": "progress", "progress": { "char": "󰝤" }, "empty": { "char": "󰁱" } } ] }, { "type": "empty", "size": 1 }, { "type": "container", "direction": "vertical", "children": [ { "type": "empty", "size": 1 }, { "type": "button", "text": "󰒮", "action": "prev()" }, { "type": "empty", "size": 1 }, { "type": "button", "text": "$status-icon", "action": "play_pause()" }, { "type": "empty", "size": 1 }, { "type": "button", "text": "󰒭", "action": "next()" } ] } ] } ] } ```
================================================ FILE: doc-site/package.json ================================================ { "name": "fum-doc-site", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite dev --host", "build": "vite build", "deploy": "npm run build && gh-pages -d build -b docs", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.16.0", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/vite": "^4.0.0", "@types/node": "^22.13.5", "gh-pages": "^6.3.0", "mdsvex": "^0.12.3", "rehype-autolink-headings": "^7.1.0", "rehype-slug": "^6.0.0", "shiki": "^3.0.0", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^6.0.0" } } ================================================ FILE: doc-site/src/app.css ================================================ @import 'tailwindcss'; @theme { --font-embed-code: "Jersey 15", serif; --color-background: #1c1b22; --color-fg: #defedf; --color-primary: #40e78a; --color-secondary: #3a86ff; --color-tertiary: #9b5de5; --color-subtle: #727169; } html { @apply scroll-smooth h-full; } body { @apply bg-background text-fg font-embed-code; } .doc p, .doc li{ @apply text-2xl; } .doc h1 { @apply relative text-6xl font-bold my-3; } .doc h2 { @apply relative text-5xl font-bold my-3; } .doc h3 { @apply relative text-4xl font-bold my-3; } .doc h4 { @apply relative text-3xl font-bold my-3; } .doc h5 { @apply relative text-2xl font-bold my-3; } .doc h6 { @apply relative text-xl font-bold my-3; } .doc h1, .doc h2, .doc h3, .doc h4, .doc h5, .doc h6 { @apply text-primary; } .doc a:not(h1 a, h2 a, h3 a, h4 a, h5 a, h6 a) { @apply text-secondary; } .doc ol { @apply list-decimal pl-4; } .doc ul { @apply list-disc pl-4; } .doc table { @apply w-full; } .doc thead { @apply bg-primary text-background; } .doc th, td { @apply px-4 py-2 border border-fg text-left; } .doc blockquote { @apply border-l-4 border-gray-400 dark:border-gray-600 pl-4 italic text-gray-700 dark:text-gray-300; } .doc pre { @apply mb-4 p-2; } .doc code:not(pre code) { @apply bg-[#16161d] font-embed-code px-2; } .doc summary { @apply text-2xl cursor-pointer hover:text-primary transition-colors duration-300 w-fit; } .doc hr { @apply my-8; } .doc pre:has(code) { position: relative; display: flex; code { overflow-x: scroll; } button.copy { position: absolute; right: 0; top: 0; height: 24px; width: 24px; margin: .5em; & span { display: inline-block; width: 100%; height: 100%; mask-size: cover; cursor: pointer; } & .ready { mask-image: url(/icons/copy.svg); background-color: var(--color-subtle); } & .success { display: none; mask-image: url(/icons/check.svg); background-color: var(--color-primary); } &.copied { & .success { display: block; } & .ready { display: none; } } } } ================================================ FILE: doc-site/src/app.d.ts ================================================ // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces declare global { namespace App { // interface Error {} // interface Locals {} // interface PageData {} // interface PageState {} // interface Platform {} } } export {}; ================================================ FILE: doc-site/src/app.html ================================================ %sveltekit.head% %sveltekit.body% ================================================ FILE: doc-site/src/global.d.ts ================================================ /// declare module '*.md' { import type { SvelteComponent } from 'svelte' export default class Comp extends SvelteComponent{} export const metadata: Record } ================================================ FILE: doc-site/src/lib/Nav.svelte ================================================ ================================================ FILE: doc-site/src/lib/SideBar.svelte ================================================
================================================ FILE: doc-site/src/lib/index.ts ================================================ export { default as Nav } from './Nav.svelte' export { default as SideBar } from './SideBar.svelte' ================================================ FILE: doc-site/src/lib/stores.ts ================================================ import { writable } from 'svelte/store' export const sideBarStore = writable(false) ================================================ FILE: doc-site/src/routes/+layout.js ================================================ export const prerender = true ================================================ FILE: doc-site/src/routes/+layout.svelte ================================================
================================================ FILE: doc-site/src/routes/+page.svelte ================================================
Logo

fum - A fully ricable tui-based mpris music client.

Get Started
================================================ FILE: doc-site/src/routes/docs/[title]/+page.svelte ================================================ {data.doc.title}
{@html data.doc.html}

{#if data.doc.prev} {data.doc.prev.title} {:else}
{/if} {#if data.doc.next} {data.doc.next.title} {/if}
================================================ FILE: doc-site/src/routes/docs/[title]/+page.ts ================================================ import type { PageLoad, EntryGenerator } from './$types' import { error } from '@sveltejs/kit' export const load: PageLoad = async ({ params }) => { const { title } = params const doc = DOCS .find(d => d.title.toLowerCase().replaceAll(' ', '_') === title ) if (!doc) throw error(404, 'Documentation Not Found.') return { doc } } export const entries: EntryGenerator = () => { return DOCS.map(d => ({ title: d.title.toLowerCase().replaceAll(' ', '_') })) } export const prerender = true ================================================ FILE: doc-site/src/vite-env.d.ts ================================================ /// declare const DOC_VERSION; string declare const DOCS: { url: string path: string raw: string title: string html: string prev?: { url: string title: string } next?: { url: string title: string } }[] ================================================ FILE: doc-site/svelte.config.js ================================================ import { mdsvex } from 'mdsvex' import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' import staticAdapter from '@sveltejs/adapter-static' import rehypeSlug from 'rehype-slug' import rehypeAutolinkHeadings from 'rehype-autolink-headings' /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: [ vitePreprocess(), mdsvex({ extensions: ['.md'], rehypePlugins: [ rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap', }] ] }) ], kit: { adapter: staticAdapter(), prerender: { entries: ['*'] } }, extensions: ['.svelte', '.md'] } export default config ================================================ FILE: doc-site/tailwind.config.js ================================================ export default { content: ['./src/**/*.{svelte,ts,js}'], theme: { extend: { colors: { background: 'var(--color-background)', primary: 'var(--color-primary)', secondary: 'var(--color-secondary)', tertiary: 'var(--color-tertiary)', }, }, }, plugins: [], } ================================================ FILE: doc-site/tsconfig.json ================================================ { "extends": "./.svelte-kit/tsconfig.json", "compilerOptions": { "allowJs": true, "checkJs": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, "strict": true, "moduleResolution": "bundler", "types": [ "vite/client", "@sveltejs/kit" ] } } ================================================ FILE: doc-site/vite.config.ts ================================================ import tailwindcss from '@tailwindcss/vite' import { sveltekit } from '@sveltejs/kit/vite' import { defineConfig } from 'vite' import fs from 'node:fs' import path from 'node:path' import { compile, escapeSvelte } from 'mdsvex' import { codeToHtml, type ShikiTransformer } from 'shiki' import rehypeSlug from 'rehype-slug' import rehypeAutolinkHeadings from 'rehype-autolink-headings' const DOC_VERSION = fs.readFileSync('DOC_VERSION', 'utf-8').trim() const DOCS = await getDocs('docs-content') export default defineConfig({ plugins: [ sveltekit(), tailwindcss() ], define: { DOC_VERSION: JSON.stringify(DOC_VERSION), DOCS: JSON.stringify(DOCS) } }) interface addCodeblockCopyButtonOptions { /** `3000` by default */ ms?: number } function addCodeblockCopyButton ({ ms = 3000 }: addCodeblockCopyButtonOptions = {}) { return { name: 'shiki-transformer-codeblock-copy-button', pre(node) { node.children.push({ type: 'element', tagName: 'button', properties: { className: [ 'copy' ], "data-code": this.source, onClick: ` navigator.clipboard.writeText(this.dataset.code); this.setAttribute('disabled', true); this.classList.add('copied'); setTimeout(() => { this.classList.remove('copied'); this.removeAttribute('disabled'); }, ${ms})` }, children: [ { type: 'element', tagName: 'span', properties: { className: [ 'ready' ] }, children: [] }, { type: 'element', tagName: 'span', properties: { className: [ 'success' ] }, children: [] } ] }); } } satisfies ShikiTransformer; } async function getDocs(docsPath: string) { const docs = await Promise.all(fs .readdirSync(docsPath) .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })) .map(async docPath => { const mdPath = path.join(__dirname, docsPath, docPath, 'doc.md') const content = fs.readFileSync(mdPath, 'utf-8') const compiledContent = await compile(content, { rehypePlugins: [ rehypeSlug, [rehypeAutolinkHeadings, { behavior: 'wrap', }], // Custom extension to replace {DOC_VERSION} to current version in the readme. () => (tree: any) => { function replaceTextNodes(node: any) { if (typeof node.value === 'string') { node.value = node.value.replace(/({|{)DOC_VERSION(}|})/g, DOC_VERSION) } if (node.children) { node.children.forEach(replaceTextNodes) } } replaceTextNodes(tree) } ], highlight: { highlighter: async (code: string, lang: string) => { return escapeSvelte( await codeToHtml( code, { lang, theme: 'kanagawa-wave', transformers: [addCodeblockCopyButton()], tabindex: -1 } ) ); } } }) const prev = compiledContent.data.fm!.prev?.split(':') const next = compiledContent.data.fm!.next?.split(':') return { url: `/docs/${docPath.slice(3)}`, raw: content, title: compiledContent.data.fm!.title, prev: prev ? { url: prev[0], title: prev[1] } : undefined, next: next ? { url: next[0], title: next[1] } : undefined, html: compiledContent.code } })) return docs } ================================================ FILE: flake.nix ================================================ { description = "Fum: A fully ricable tui-based music client"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; outputs = { self, nixpkgs, flake-utils, }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs {inherit system;}; updateScript = pkgs.writeShellScriptBin "update-fum" '' nix flake update ${pkgs.nix-update}/bin/nix-update -vr 'v(.*)' --flake --commit fum ''; in { packages.fum = pkgs.rustPlatform.buildRustPackage rec { pname = "fum"; version = "0.9.17"; src = pkgs.fetchFromGitHub { owner = "qxb3"; repo = pname; rev = "v${version}"; hash = "sha256-E9Z8bs5bdNcXHRJIkzcISIz8R1wnZu8sO6tXQp+5bpQ="; }; cargoLock = { lockFile = ./Cargo.lock; }; nativeBuildInputs = with pkgs; [ pkg-config autoPatchelfHook ]; buildInputs = with pkgs; [ openssl dbus libgcc ]; OPENSSL_DIR = "${pkgs.openssl.dev}"; OPENSSL_LIB_DIR = "${pkgs.openssl.out}/lib"; OPENSSL_INCLUDE_DIR = "${pkgs.openssl.dev}/include"; meta = with pkgs.lib; { description = "A fully ricable tui-based music client"; homepage = "https://github.com/qxb3/fum"; license = licenses.mit; maintainers = with maintainers; [linuxmobile]; platforms = platforms.linux; }; }; packages.default = self.packages.${system}.fum; devShells.default = pkgs.mkShell { inputsFrom = [self.packages.${system}.fum]; buildInputs = with pkgs; [ cargo rust-analyzer rustfmt clippy updateScript ]; }; apps.update = { type = "app"; program = "${updateScript}/bin/update-fum"; }; nixosModules.fum = import ./modules/default.nix; homeManagerModules.fum = { config, pkgs, lib, ... }: import ./nix/hm-module.nix { inherit config pkgs lib; fumPackage = self.packages.${system}.fum; }; }); } ================================================ FILE: nix/default.nix ================================================ { nixosModules = import ./modules/default.nix; homeManagerModules = import ./hm-module.nix; } ================================================ FILE: nix/hm-module.nix ================================================ { config, lib, fumPackage, ... }: let inherit (lib.types) bool package int str; inherit (lib.modules) mkIf; inherit (lib.options) mkOption mkEnableOption; boolToString = x: if x then "true" else "false"; cfg = config.programs.fum; filterOptions = options: builtins.filter (opt: builtins.elemAt opt 1 != "") options; in { options.programs.fum = { enable = mkEnableOption "Enable the fum music client."; package = mkOption { description = "The fum music client package."; type = package; default = fumPackage; }; players = mkOption { description = "List of media players to control."; type = lib.types.listOf str; default = ["spotify"]; }; use_active_player = mkOption { description = "Whether to use the active player."; type = bool; default = true; }; align = mkOption { description = "Alignment of the UI."; type = str; default = "center"; }; direction = mkOption { description = "Direction of the UI."; type = str; default = "vertical"; }; flex = mkOption { description = "Flex alignment of the UI."; type = str; default = "start"; }; width = mkOption { description = "Width of the UI."; type = int; default = 20; }; height = mkOption { description = "Height of the UI."; type = int; default = 18; }; layout = mkOption { description = "Layout configuration."; type = lib.types.listOf lib.types.attrs; default = []; }; }; config = mkIf cfg.enable { home.packages = [cfg.package]; xdg.configFile."fum/config.json".text = let formatOption = name: value: ''"${name}": ${value}''; formatConfig = options: builtins.concatStringsSep ",\n" (map (opt: formatOption (builtins.head opt) (builtins.elemAt opt 1)) options); in '' { ${formatConfig (filterOptions [ ["players" (builtins.toJSON cfg.players)] ["use_active_player" (boolToString cfg.use_active_player)] ["align" (builtins.toJSON cfg.align)] ["direction" (builtins.toJSON cfg.direction)] ["flex" (builtins.toJSON cfg.flex)] ["width" (toString cfg.width)] ["height" (toString cfg.height)] ["layout" (builtins.toJSON cfg.layout)] ])} } ''; }; } ================================================ FILE: src/action.rs ================================================ use std::time::Duration; use mpris::{LoopStatus, Player}; use serde::{de, Deserialize}; use crate::{fum::Fum, regexes::{BACKWARD_RE, FORWARD_RE, POSITION_RE, VAR_SET_RE, VAR_TOGGLE_RE, VOLUME_RE}, FumResult}; macro_rules! if_player { ($player:expr, $callback:expr) => { if let Some(player) = $player { $callback(player)?; } }; } #[derive(Debug, Clone)] pub enum VolumeType { Increase(f64), Decrease(f64), Set(f64) } #[derive(Debug, Clone)] pub enum Action { Quit, Stop, Play, Pause, Prev, PlayPause, Next, ShuffleOff, ShuffleToggle, ShuffleOn, LoopNone, LoopPlaylist, LoopTrack, LoopCycle, Forward(i64), Backward(i64), Position(u64), Volume(VolumeType), Toggle(String, String, String), Set(String, String) } impl<'de> Deserialize<'de> for Action { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { let action_str: &str = Deserialize::deserialize(deserializer)?; match action_str { "quit()" => Ok(Action::Quit), "stop()" => Ok(Action::Stop), "play()" => Ok(Action::Play), "pause()" => Ok(Action::Pause), "prev()" => Ok(Action::Prev), "play_pause()" => Ok(Action::PlayPause), "next()" => Ok(Action::Next), "shuffle_off()" => Ok(Action::ShuffleOff), "shuffle_toggle()" => Ok(Action::ShuffleToggle), "shuffle_on()" => Ok(Action::ShuffleOn), "loop_none()" => Ok(Action::LoopNone), "loop_track()" => Ok(Action::LoopTrack), "loop_playlist()" => Ok(Action::LoopPlaylist), "loop_cycle()" => Ok(Action::LoopCycle), // forward() action a if FORWARD_RE.is_match(a) => { if let Some(captures) = FORWARD_RE.captures(a) { match captures[1].parse::() { Ok(offset) => return Ok(Action::Forward(offset)), Err(_) => return Err(de::Error::custom("Invalid forward() offset format")) } } Err(de::Error::custom("Invalid forward() format")) }, // backward() action a if BACKWARD_RE.is_match(a) => { if let Some(captures) = BACKWARD_RE.captures(a) { match captures[1].parse::() { Ok(offset) => return Ok(Action::Backward(offset)), Err(_) => return Err(de::Error::custom("Invalid backward() offset format")) } } Err(de::Error::custom("Invalid backward() format")) }, // position() action a if POSITION_RE.is_match(a) => { if let Some(captures) = POSITION_RE.captures(a) { match captures[1].parse::() { Ok(position) => return Ok(Action::Position(position)), Err(_) => return Err(de::Error::custom("Invalid position() offset format")) } } Err(de::Error::custom("Invalid position() format")) }, // volume() action a if VOLUME_RE.is_match(a) => { if let Some(captures) = VOLUME_RE.captures(a) { match captures[1].to_string().as_str() { c if c.starts_with("+") => { let value = c .replace("+", "") .parse::() .map_err(|_| de::Error::custom("Failed to parse volume() value."))?; return Ok(Action::Volume(VolumeType::Increase(value.min(100.0)))); }, c if c.starts_with("-") => { let value = c .replace("-", "") .parse::() .map_err(|_| de::Error::custom("Failed to parse volume() value."))?; return Ok(Action::Volume(VolumeType::Decrease(value.min(100.0)))); }, c => { let value = c .parse::() .map_err(|_| de::Error::custom("Failed to parse volume() value."))?; return Ok(Action::Volume(VolumeType::Set(value.min(100.0)))) } } } Err(de::Error::custom("Unknown exception while parsing volume() action")) } // toggle() action a if VAR_TOGGLE_RE.is_match(a) => { if let Some(captures) = VAR_TOGGLE_RE.captures(a) { let name = captures[1].to_string(); let first = captures[2].to_string(); let second = captures[3].to_string(); return Ok(Action::Toggle(name, first, second)); } Err(de::Error::custom("Unknown exception while parsing toggle() action")) }, // set() action a if VAR_SET_RE.is_match(a) => { if let Some(captures) = VAR_SET_RE.captures(a) { let name = captures[1].to_string(); let first = captures[2].to_string(); return Ok(Action::Set(name, first)); } Err(de::Error::custom("Unknown exception while parsing set() action")) }, // Error if forward() / backward() has no value inside "forward()" => Err(de::Error::custom(format!("Invalid forward() format, needs value inside"))), "backward()" => Err(de::Error::custom(format!("Invalid backward() format, needs value inside"))), _ => Err(de::Error::custom(format!("Unknown action: {}", action_str))) } } } impl Action { pub fn run(action: &Action, fum: &mut Fum) -> FumResult<()> { match action { Action::Quit => fum.exit = true, Action::Stop => if_player!(&fum.player, |player: &Player| player.stop()), Action::Play => if_player!(&fum.player, |player: &Player| player.play()), Action::Pause => if_player!(&fum.player, |player: &Player| player.pause()), Action::Prev => if_player!(&fum.player, |player: &Player| player.previous()), Action::PlayPause => if_player!(&fum.player, |player: &Player| player.play_pause()), Action::Next => if_player!(&fum.player, |player: &Player| player.next()), Action::ShuffleOff => if_player!(&fum.player, |player: &Player| player.set_shuffle(true)), Action::ShuffleToggle => if_player!(&fum.player, |player: &Player| player.set_shuffle(!player.get_shuffle()?)), Action::ShuffleOn => if_player!(&fum.player, |player: &Player| player.set_shuffle(false)), Action::LoopNone => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::None)), Action::LoopPlaylist => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::Playlist)), Action::LoopTrack => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::Track)), Action::LoopCycle => { if let Some(player) = &fum.player { let loop_status = player.get_loop_status()?; match loop_status { LoopStatus::None => player.set_loop_status(LoopStatus::Playlist)?, LoopStatus::Playlist => player.set_loop_status(LoopStatus::Track)?, LoopStatus::Track => player.set_loop_status(LoopStatus::None)? } } }, Action::Forward(offset) => if_player!(&fum.player, |player: &Player| { fum.redraw = true; // if offset is -1, set position to music length if *offset == -1 { if let Some(track_id) = &fum.state.meta.track_id { return player.set_position(track_id.clone(), &fum.state.meta.length) } } player.seek_forwards(&Duration::from_millis(*offset as u64)) }), Action::Backward(offset) => if_player!(&fum.player, |player: &Player| { fum.redraw = true; // if offset is -1, set position to music start if *offset == -1 { if let Some(track_id) = &fum.state.meta.track_id { return player.set_position(track_id.clone(), &Duration::from_secs(0)) } } player.seek_backwards(&Duration::from_millis(*offset as u64)) }), Action::Position(position) => { fum.redraw = true; if let Some(player) = &fum.player { if let Some(track_id) = &fum.state.meta.track_id { player.set_position(track_id.clone(), &Duration::from_secs(*position))?; } } } Action::Volume(volume_type) => if_player!(&fum.player, |player: &Player| { fum.redraw = true; let current_volume = player.get_volume().unwrap_or(0.0) * 100.0; match volume_type { VolumeType::Increase(value) => player.set_volume(((current_volume + *value) / 100.0).min(1.0)), VolumeType::Decrease(value) => player.set_volume(((current_volume - *value) / 100.0).min(1.0)), VolumeType::Set(value) => player.set_volume((*value / 100.0).min(1.0)) } }), Action::Toggle(name, first, second) => { fum.redraw = true; if let Some(current) = &fum.state.vars.get(name) { if *current == first { fum.state.vars.insert(name.to_string(), second.to_string()); } else { fum.state.vars.insert(name.to_string(), first.to_string()); } } }, Action::Set(name, first) => { fum.redraw = true; // Just checks wether var exists, don't care about the value if fum.state.vars.get(name).is_some() { fum.state.vars.insert(name.to_string(), first.to_string()); } } } Ok(()) } } ================================================ FILE: src/cli.rs ================================================ use clap::{Parser, Subcommand}; use expanduser::expanduser; use crate::{config::{Align, Config}, fum::FumResult}; #[derive(Subcommand)] pub enum Commands { ListPlayers } #[derive(Parser)] #[command(name = "fum", version, about)] pub struct FumCli { #[arg(short, long, value_name = "config file", default_value = "~/.config/fum/config.jsonc")] config: Option, #[arg(short, long, value_name = "string[]", value_delimiter = ',')] players: Option>, #[arg(long, value_name = "boolean")] use_active_player: Option, #[arg(long, value_name = "number")] fps: Option, #[arg(short, long, value_name = "center,top,left,bottom,right,top-left,top-right,bottom-left,bottom-right")] align: Option, #[command(subcommand)] pub command: Option } pub fn run() -> FumResult<(FumCli, Config)> { let fum_cli = FumCli::parse(); let config_path = expanduser(fum_cli.config.as_ref().unwrap()) .map_err(|err| format!("Failed to expand path: {err}"))?; let mut config = Config::load(&config_path)?; if let Some(players) = fum_cli.players.as_ref() { config.players = players.to_owned(); } if let Some(use_active_player) = fum_cli.use_active_player.as_ref() { config.use_active_player = use_active_player.to_owned(); } if let Some(fps) = fum_cli.fps.as_ref() { config.fps = fps.to_owned(); } if let Some(align) = fum_cli.align.as_ref() { let align = Align::from_str(align.as_str()) .ok_or("Invalid value for 'align'".to_string())?; config.align = align; } Ok((fum_cli, config)) } ================================================ FILE: src/config/config.rs ================================================ use std::{collections::HashMap, fs, path::PathBuf}; use expanduser::expanduser; use ratatui::style::Color; use serde::Deserialize; use crate::{action::Action, fum::FumResult, regexes::JSONC_COMMENT_RE, widget::{ContainerFlex, Direction, FumWidget}}; use super::{defaults::{align, bg, border, cover_art_ascii, direction, fg, flex, fps, height, keybinds, layout, padding, players, use_active_player, width}, keybind::Keybind}; #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Align { Center, Top, Left, Bottom, Right, #[serde(rename = "top-left")] TopLeft, #[serde(rename = "top-right")] TopRight, #[serde(rename = "bottom-left")] BottomLeft, #[serde(rename = "bottom-right")] BottomRight, } impl Align { pub fn from_str(str: &str) -> Option { match str { "center" => Some(Self::Center), "top" => Some(Self::Top), "left" => Some(Self::Left), "bottom" => Some(Self::Bottom), "top-left" => Some(Self::TopLeft), "top-right" => Some(Self::TopRight), "bottom-left" => Some(Self::BottomLeft), "bottom-right" => Some(Self::BottomRight), _ => None } } } #[derive(Debug, Clone, Deserialize)] pub struct Config { #[serde(default = "players")] pub players: Vec, #[serde(default = "use_active_player")] pub use_active_player: bool, #[serde(default = "fps")] pub fps: u64, #[serde(default = "keybinds")] pub keybinds: HashMap, #[serde(default = "align")] pub align: Align, #[serde(default = "direction")] pub direction: Direction, #[serde(default = "flex")] pub flex: ContainerFlex, #[serde(default = "width")] pub width: u16, #[serde(default = "height")] pub height: u16, #[serde(default = "border")] pub border: bool, #[serde(default = "padding")] pub padding: [u16; 2], #[serde(default = "bg")] pub bg: Color, #[serde(default = "fg")] pub fg: Color, #[serde(default = "cover_art_ascii")] pub cover_art_ascii: String, #[serde(default = "layout")] pub layout: Vec, } impl Default for Config { fn default() -> Self { Self { players: players(), use_active_player: use_active_player(), fps: fps(), keybinds: keybinds(), align: align(), direction: direction(), flex: flex(), width: width(), height: height(), border: border(), padding: padding(), bg: bg(), fg: fg(), cover_art_ascii: cover_art_ascii(), layout: layout() } } } impl Config { pub fn load(path: &PathBuf) -> FumResult { match fs::read_to_string(path) { Ok(config_file) => { // Clean config file for comments let cleaned_config_file = JSONC_COMMENT_RE.replace_all(&config_file, "") .to_string(); let mut config: Config = serde_json::from_str(&cleaned_config_file) .map_err(|err| format!("Failed to parse config: {err}"))?; // Get expanded path of cover_art_ascii let cover_art_ascii_path = expanduser(&config.cover_art_ascii) .map_err(|err| format!("Failed to expand path of cover_art_ascii: {err}"))?; // Load the cover_art_ascii match fs::read_to_string(cover_art_ascii_path) { Ok(cover_art_ascii) => config.cover_art_ascii = cover_art_ascii, Err(_) => config.cover_art_ascii = "".to_string() } // Convert fps into millis config.fps = 1000 / config.fps; Ok(config) }, Err(_) => Ok(Config::default()) } } } ================================================ FILE: src/config/defaults.rs ================================================ use std::collections::HashMap; use ratatui::style::Color; use crate::{action::Action, utils::widget::generate_id, widget::{ContainerFlex, CoverArtResize, Direction, FumWidget, LabelAlignment, ProgressOption}}; use super::{keybind::Keybind, Align}; pub fn players() -> Vec { vec!["spotify".to_string()] } pub fn use_active_player() -> bool { false } pub fn fps() -> u64 { 10 } pub fn align() -> Align { Align::Center } pub fn direction() -> Direction { Direction::Vertical } pub fn flex() -> ContainerFlex { ContainerFlex::Start } pub fn width() -> u16 { 19 } pub fn height() -> u16 { 15 } pub fn border() -> bool { false } pub fn padding() -> [u16; 2] { [0, 0] } pub fn bg() -> Color { Color::Reset } pub fn fg() -> Color { Color::Reset } pub fn cover_art_ascii() -> String { "".to_string() } pub fn keybinds() -> HashMap { HashMap::from([ (Keybind::Many([Keybind::Esc, Keybind::Char('q')].to_vec()), Action::Quit), (Keybind::Char('h'), Action::Prev), (Keybind::Char('l'), Action::Next), (Keybind::Char(' '), Action::PlayPause) ]) } pub fn layout() -> Vec { Vec::from([ FumWidget::CoverArt { width: None, height: None, border: false, resize: CoverArtResize::Scale, bg: None, fg: None, }, FumWidget::Empty { size: 1, bg: None, fg: None }, FumWidget::Container { width: None, height: None, direction: Direction::Vertical, border: false, padding: padding(), flex: ContainerFlex::default(), bg: None, fg: None, children: Vec::from([ FumWidget::Label { text: "$title".to_string(), direction: Direction::default(), align: LabelAlignment::Center, truncate: true, bold: false, bg: None, fg: None }, FumWidget::Label { text: "$artists".to_string(), direction: Direction::default(), align: LabelAlignment::Center, truncate: true, bold: false, bg: None, fg: None }, FumWidget::Empty { size: 1, bg: None, fg: None }, FumWidget::Container { width: None, height: Some(1), direction: Direction::Horizontal, border: false, padding: padding(), flex: ContainerFlex::SpaceAround, bg: None, fg: None, children: Vec::from([ FumWidget::Button { id: generate_id(), text: "󰒮".to_string(), direction: Direction::default(), action: Some(Action::Prev), action_secondary: None, exec: None, bold: false, bg: None, fg: None }, FumWidget::Button { id: generate_id(), text: "$status-icon".to_string(), direction: Direction::default(), action: Some(Action::PlayPause), action_secondary: None, exec: None, bold: false, bg: None, fg: None }, FumWidget::Button { id: generate_id(), text: "󰒭".to_string(), direction: Direction::default(), action: Some(Action::Next), action_secondary: None, exec: None, bold: false, bg: None, fg: None } ]) }, FumWidget::Progress { id: generate_id(), size: None, direction: Direction::Horizontal, progress: ProgressOption { char: '󰝤', bg: None, fg: None }, empty: ProgressOption { char: '󰁱', bg: None, fg: None } }, FumWidget::Container { width: None, height: Some(1), border: false, padding: padding(), direction: Direction::Horizontal, flex: ContainerFlex::SpaceBetween, bg: None, fg: None, children: Vec::from([ FumWidget::Label { text: "$position".to_string(), direction: Direction::default(), align: LabelAlignment::Left, truncate: false, bold: false, bg: None, fg: None }, FumWidget::Label { text: "$length".to_string(), direction: Direction::default(), align: LabelAlignment::Right, truncate: false, bold: false, bg: None, fg: None } ]) } ]) } ]) } ================================================ FILE: src/config/keybind.rs ================================================ use crossterm::event::{KeyCode, KeyModifiers}; use serde::{de, Deserialize}; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum Keybind { Backspace, Enter, Left, Up, Right, Down, End, PageUp, PageDown, Tab, BackTab, Delete, Insert, Esc, Caps, F(u8), Char(char), Many(Vec), WithModifier(KeyModifiers, Box) } impl<'de> Deserialize<'de> for Keybind { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de> { let keybind: &str = Deserialize::deserialize(deserializer)?; if keybind.contains(";") { let keybinds = keybind .split(";") .filter(|k| !k.is_empty()) .map(|k| Keybind::parse_keybind(k.trim())) .collect::, D::Error>>()?; return Ok(Keybind::Many(keybinds)); } Keybind::parse_keybind(keybind) } } impl Keybind { pub fn into_keycode(&self) -> KeyCode { match self { Keybind::Backspace => KeyCode::Backspace, Keybind::Enter => KeyCode::Enter, Keybind::Left => KeyCode::Left, Keybind::Up => KeyCode::Up, Keybind::Right => KeyCode::Right, Keybind::Down => KeyCode::Down, Keybind::End => KeyCode::End, Keybind::PageUp => KeyCode::PageUp, Keybind::PageDown => KeyCode::PageDown, Keybind::Tab => KeyCode::Tab, Keybind::BackTab => KeyCode::BackTab, Keybind::Delete => KeyCode::Delete, Keybind::Insert => KeyCode::Insert, Keybind::Esc => KeyCode::Esc, Keybind::Caps => KeyCode::CapsLock, Keybind::F(u8) => KeyCode::F(*u8), Keybind::Char(char) => KeyCode::Char(*char), Keybind::Many(_) => unreachable!(), Keybind::WithModifier(_, _) => unreachable!() } } fn parse_keybind(keybind: &str) -> Result where D: de::Error { match keybind { "backspace" => Ok(Keybind::Backspace), "enter" => Ok(Keybind::Enter), "left" => Ok(Keybind::Left), "up" => Ok(Keybind::Up), "right" => Ok(Keybind::Right), "down" => Ok(Keybind::Down), "end" => Ok(Keybind::End), "page_up" => Ok(Keybind::PageUp), "page_down" => Ok(Keybind::PageDown), "tab" => Ok(Keybind::Tab), "back_tab" => Ok(Keybind::BackTab), "delete" => Ok(Keybind::Delete), "insert" => Ok(Keybind::Insert), "caps" => Ok(Keybind::Caps), "esc" => Ok(Keybind::Esc), // Fn keys. k if k.starts_with('f') => { match k[1..].parse::() { Ok(fn_num) => Ok(Keybind::F(fn_num)), Err(_) => Err(de::Error::custom("Invalid fn key format")) } }, // Individual key. k if k.len() == 1 => { match k.chars().next() { Some(char) => Ok(Keybind::Char(char)), None => Err(de::Error::custom(format!("Invalid keyboard key: {k}"))) } }, k if k.contains("+") => { let split = k.split('+'); let len = split.to_owned().count(); let modifier_keys = split.to_owned().take(len.saturating_sub(1)).collect::>(); let key = split.to_owned().last().unwrap(); let mut modifiers = KeyModifiers::NONE; for modifier in modifier_keys { let modifier = match modifier { "shift" => KeyModifiers::SHIFT, "ctrl" => KeyModifiers::CONTROL, "alt" => KeyModifiers::ALT, "super" => KeyModifiers::SUPER, "hyper" => KeyModifiers::HYPER, "meta" => KeyModifiers::META, _ => return Err(de::Error::custom(format!("Unknown key modifier: {modifier}"))) }; modifiers = modifiers | modifier; } let keybind = Keybind::parse_keybind(key)?; Ok(Keybind::WithModifier(modifiers, Box::new(keybind))) }, _ => Err(de::Error::custom(format!("Unknown keybind: {keybind}"))) } } } ================================================ FILE: src/config/mod.rs ================================================ mod config; mod defaults; mod keybind; pub use config::*; pub use keybind::*; ================================================ FILE: src/fum.rs ================================================ use core::error; use std::{io::{stdout, Stdout}, process::{Command, Stdio}, time::Duration}; use crossterm::{event::{self, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind}, execute}; use mpris::Player; use ratatui::{layout::Position, prelude::CrosstermBackend, Terminal}; use ratatui_image::picker::Picker; use crate::{action::{Action, VolumeType}, config::{Config, Keybind}, meta::Meta, state::FumState, ui::Ui, utils, widget::{Direction, SliderSource}}; pub type FumResult = std::result::Result>; pub struct Fum<'a> { config: &'a Config, pub terminal: Terminal>, pub ui: Ui<'a>, pub picker: Picker, pub player: Option, pub state: FumState, // drag state pub dragging: bool, pub start_drag: Option, pub current_drag: Option, pub drag_action: Option, pub redraw: bool, pub exit: bool } impl<'a> Fum<'a> { pub fn new(config: &'a Config) -> FumResult { let player = Meta::get_player(&config).ok(); let picker = Picker::from_query_stdio()?; let meta = match &player { Some(player) => Meta::fetch(player, &picker, None).unwrap_or(Meta::default()), None => Meta::default() }; // Enable mouse capture execute!(stdout(), EnableMouseCapture)?; Ok(Self { config, terminal: ratatui::init(), ui: Ui::new(config), picker, player, state: FumState::new(meta), // drag state dragging: false, start_drag: None, current_drag: None, drag_action: None, redraw: true, // Draw at startup exit: false }) } pub fn run(&mut self) -> FumResult<()> { while !self.exit { if self.redraw { self.terminal.draw(|frame| { self.ui.draw(frame, &mut self.state); self.redraw = false; })?; } self.update_meta(); self.term_events()?; } utils::terminal::restore(); Ok(()) } fn term_events(&mut self) -> FumResult<()> { if event::poll(Duration::from_millis(self.config.fps))? { let event = event::read()?; match event { Event::Key(key) if key.kind == KeyEventKind::Press => { for (keybind, action) in self.config.keybinds.iter() { match keybind { Keybind::Many(keybinds) => { for keybind in keybinds { if let Keybind::WithModifier(modifiers, keybind) = keybind { if key.modifiers.intersects(*modifiers) { if key.code == keybind.into_keycode() { Action::run(action, self)?; } } } else { if key.code == keybind.into_keycode() { Action::run(action, self)?; } } } }, Keybind::WithModifier(modifiers, keybind) => { if key.modifiers.intersects(*modifiers) { if key.code == keybind.into_keycode() { Action::run(action, self)?; } } }, keybind => { if key.code == keybind.into_keycode() { Action::run(action, self)?; } } } } }, Event::Mouse(mouse) => { match mouse.kind { // Button click mouse left. MouseEventKind::Down(MouseButton::Left) => { if let Some((action, _, exec)) = self.ui.click(mouse.column, mouse.row, &self.state.buttons) { let action = action.to_owned(); let exec = exec.to_owned(); if let Some(action) = action { Action::run(&action, self)?; } if let Some(exec) = exec { let parts: Vec<&str> = exec.split_whitespace().collect(); if let Some(command) = parts.get(0) { let _ = Command::new(command) // Ignore result .args(&parts[1..]) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn(); } } } }, // Button click mouse right. MouseEventKind::Down(MouseButton::Right) => { if let Some((_, action_secondary, _)) = self.ui.click(mouse.column, mouse.row, &self.state.buttons) { let action_secondary = action_secondary.to_owned(); if let Some(action_secondary) = action_secondary { Action::run(&action_secondary, self)?; } } }, // Mouse left drag. MouseEventKind::Drag(MouseButton::Left) => { if !self.dragging && self.start_drag.is_none() { self.dragging = true; self.start_drag = Some(Position::new(mouse.column, mouse.row)); } if self.dragging { self.current_drag = Some(Position::new(mouse.column, mouse.row)); if let Some(start_drag) = &self.start_drag { if let Some(current_drag) = &self.current_drag { if let Some((rect, direction, widget)) = self.ui.drag(start_drag, &self.state.sliders) { let value: f64 = match direction { Direction::Horizontal => ((current_drag.x as f64 - rect.x as f64) / rect.width as f64).clamp(0.0, 1.0), Direction::Vertical => (1.0 - ((current_drag.y as f64 - rect.y as f64) / rect.height as f64)).clamp(0.0, 1.0) }; match widget { SliderSource::Progress => { let position = value * self.state.meta.length.as_secs() as f64; if position >= self.state.meta.length.as_secs() as f64 { self.dragging = false; self.start_drag = None; self.current_drag = None; } Action::run(&Action::Position(position as u64), self)?; }, SliderSource::Volume => { let volume = value * 100.0; Action::run(&Action::Volume(VolumeType::Set(volume)), self)?; } } } } } } }, // Mouse left release. MouseEventKind::Up(MouseButton::Left) => { self.dragging = false; self.start_drag = None; self.current_drag = None; }, _ => {} } }, Event::Resize(_, _) => { self.redraw = true; } _ => {} } } Ok(()) } fn update_meta(&mut self) { if let Some(player) = &self.player { let meta = Meta::fetch(player, &self.picker, Some(&self.state.meta)) .unwrap_or(Meta::default()); self.redraw = meta.changed; self.state.meta = meta; return; } self.player = Meta::get_player(&self.config).ok(); self.state.meta = Meta::default(); } } ================================================ FILE: src/main.rs ================================================ mod cli; mod fum; mod state; mod meta; mod ui; mod utils; mod text; mod widget; mod config; mod action; mod regexes; use fum::{Fum, FumResult}; use mpris::PlayerFinder; fn main() -> FumResult<()> { let (cli, config) = cli::run()?; if let Some(command) = &cli.command { match command { cli::Commands::ListPlayers => { let player_finder = PlayerFinder::new() .map_err(|err| format!("Failed to connect to D-Bus: {err}."))?; let players = player_finder .find_all() .map_err(|err| format!("There is no any active players: {err}."))?; println!("Active Players:"); for player in players { let identity = player.identity().to_lowercase(); let bus_name = player.bus_name(); println!("* {identity} ~> {bus_name}"); } return Ok(()); } } } Fum::new(&config)? .run()?; Ok(()) } ================================================ FILE: src/meta.rs ================================================ use std::{fs, io::{self, Cursor}, str::FromStr, time::Duration}; use base64::{prelude::BASE64_STANDARD, Engine}; use image::ImageReader; use mpris::{Metadata, MetadataValue, PlaybackStatus, Player, PlayerFinder, TrackID}; use ratatui_image::{picker::Picker, protocol::StatefulProtocol}; use reqwest::{header::RANGE, Url}; use crate::{config::Config, fum::FumResult}; #[derive(Clone)] pub struct CoverArt { pub url: String, pub image: StatefulProtocol } #[derive(Clone)] pub struct Meta { pub metadata: Metadata, pub track_id: Option, pub title: String, pub artists: Vec, pub album: String, pub status: PlaybackStatus, pub status_icon: char, pub status_text: String, pub position: Duration, pub length: Duration, pub volume: f64, pub cover_art: Option, pub changed: bool } impl Default for Meta { fn default() -> Self { Self { metadata: Metadata::default(), track_id: None, title: "No Music".to_string(), artists: vec!["Artist".to_string()], album: "Album".to_string(), status: PlaybackStatus::Stopped, status_icon: Meta::get_status_icon(&PlaybackStatus::Stopped), status_text: "stopped".to_string(), position: Duration::from_secs(0), length: Duration::from_secs(0), volume: 0.0, cover_art: None, changed: false } } } impl Meta { pub fn fetch(player: &Player, picker: &Picker, current: Option<&Self>) -> FumResult { let metadata = Meta::get_metadata(player)?; let track_id = Meta::get_trackid(&metadata).ok(); let title = Meta::get_title(&metadata)?; let artists = Meta::get_artists(&metadata).unwrap_or(vec!["Artist".to_string()]); let album = Meta::get_album(&metadata).unwrap_or("Album".to_string()); let status = Meta::get_status(player)?; let status_icon = Meta::get_status_icon(&status); let status_text = Meta::get_status_text(&status); let position = Meta::get_position(player)?; let length = Meta::get_length(&metadata)?; let volume = Meta::get_volume(player).unwrap_or(0.0); let cover_art = Meta::get_cover_art(&metadata, picker, current).ok(); let mut changed = false; if let Some(current) = ¤t { if current.title != title || current.artists != artists || current.status != status || current.length != length || current.volume != volume || position.as_secs() > current.position.as_secs() || position.as_secs() < current.position.as_secs() { changed = true; } } Ok(Self { metadata, track_id, title, artists, album, status, status_icon, status_text, position, length, volume, cover_art, changed }) } pub fn get_player(config: &Config) -> FumResult { let finder = PlayerFinder::new() .map_err(|err| format!("Failed to connect to D-Bus: {:?}.", err))?; let players = finder .find_all() .map_err(|err| format!("There is no any active players: {:?}.", err))?; for player in players { let identity = player.identity().to_lowercase(); let bus_name = player.bus_name().to_lowercase(); if config.players.iter().any(|p| p.to_lowercase() == identity.to_lowercase() || bus_name.starts_with(&p.to_lowercase()) ) { return Ok(player); } } // Find the most likely player to be used if config.use_active_player { let active = finder.find_active() .map_err(|err| format!("'use-active-player' is set to true but failed to get active player: {err}"))?; return Ok(active); } Err(Box::new( io::Error::new( io::ErrorKind::Other, "Failed to find any specified players" ) )) } pub fn get_metadata(player: &Player) -> FumResult { let metadata = player.get_metadata()?; Ok(metadata) } pub fn get_trackid(metadata: &Metadata) -> FumResult { let trackid = metadata.track_id() .ok_or("Failed to get track_id")?; Ok(trackid) } pub fn get_title(metadata: &Metadata) -> FumResult { let title = metadata .title() .map(|t| t.to_string()) .ok_or("Failed to get xesam:title")?; Ok(title) } pub fn get_artists(metadata: &Metadata) -> FumResult> { let metadata = metadata .artists() .map(|a| a.iter().map(|a| a.to_string()).collect()) .ok_or("Failed to get xesam:title.".to_string())?; Ok(metadata) } pub fn get_status(player: &Player) -> FumResult { let status = player .get_playback_status() .map_err(|err| format!("Failed to get player playback_status: {err}"))?; Ok(status) } pub fn get_status_icon(status: &PlaybackStatus) -> char { match status { PlaybackStatus::Stopped => '󰓛', PlaybackStatus::Playing => '󰏤', PlaybackStatus::Paused => '󰐊' } } pub fn get_status_text(status: &PlaybackStatus) -> String { match status { PlaybackStatus::Stopped => "stopped".to_string(), PlaybackStatus::Playing => "playing".to_string(), PlaybackStatus::Paused => "paused".to_string() } } pub fn get_position(player: &Player) -> FumResult { let position = player.get_position() .map_err(|err| format!("Failed to get player position: {err}"))?; Ok(position) } pub fn get_length(metadata: &Metadata) -> FumResult { let length = metadata .length() .ok_or("Failed to get mpris:length".to_string())?; Ok(length) } pub fn get_album(metadata: &Metadata) -> FumResult { let album = metadata .album_name() .map(|a| a.to_string()) .ok_or("Failed to get xesam:album".to_string())?; Ok(album) } pub fn get_volume(player: &Player) -> FumResult { let volume = player.get_volume() .map_err(|err| format!("Failed to get player volume: {err}"))?; Ok(volume) } pub fn get_custom_meta(metadata: &Metadata, key: String) -> String { let value = metadata.get(&key); match value { Some(value) => match value { MetadataValue::String(str) => str.to_string(), MetadataValue::Bool(bool) => bool.to_string(), MetadataValue::U8(u8) => u8.to_string(), MetadataValue::U16(u16) => u16.to_string(), MetadataValue::U32(u32) => u32.to_string(), MetadataValue::U64(u64) => u64.to_string(), MetadataValue::I16(i16) => i16.to_string(), MetadataValue::I32(i32) => i32.to_string(), MetadataValue::I64(i64) => i64.to_string(), MetadataValue::F64(f64) => f64.to_string(), MetadataValue::Unsupported | _ => "!Unsupported".to_string() }, None => "!NotFound".to_string() } } pub fn get_cover_art(metadata: &Metadata, picker: &Picker, current: Option<&Meta>) -> FumResult { let art_url = metadata .get("mpris:artUrl") .ok_or("Failed to get mpris:artUrl")?; if let mpris::MetadataValue::String(art_url) = art_url { if let Some(current) = ¤t { if let Some(current_art) = ¤t.cover_art { if current_art.url == *art_url { return Ok(current_art.clone()); } } } // Handle file:// scheme if art_url.starts_with("file://") { let art_path = Url::from_str(&art_url) .map_err(|err| format!("Failed to parse url: {art_url}: {err}"))? .to_file_path() .map_err(|_| format!("Failed to convert url: {art_url} to file_path"))?; let bytes = fs::read(&art_path) .map_err(|err| format!("Failed to read art file: {err}"))?; let cover_art = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|_| "Unknown image file_type".to_string())? .decode() .map_err(|_| "Failed to decode image".to_string())?; return Ok(CoverArt { url: art_url.to_string(), image: picker.new_resize_protocol(cover_art) }) } // Handle base64 if art_url.starts_with("data:") { let base64_data = art_url .split_once("base64,") .ok_or("Invalid base64 url format")? .1; let bytes = BASE64_STANDARD.decode(base64_data) .map_err(|err| format!("Failed to decode base64 data: {err}"))?; let cover_art = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|_| "Unknown image file_type".to_string())? .decode() .map_err(|_| "Failed to decode image".to_string())?; return Ok(CoverArt { url: art_url.to_string(), image: picker.new_resize_protocol(cover_art), }); } let client = reqwest::blocking::Client::new(); let resp = client .get(art_url) .header(RANGE, "bytes=0-1048576") .send() .map_err(|_| "Failed to fetch art url".to_string())?; let bytes = resp.bytes() .map_err(|_| "Failed to get art image bytes".to_string())?; let cover_art = ImageReader::new(Cursor::new(bytes)) .with_guessed_format() .map_err(|_| "Unknown image file_type".to_string())? .decode() .map_err(|_| "Failed to decode image".to_string())?; return Ok(CoverArt { url: art_url.to_string(), image: picker.new_resize_protocol(cover_art) }) } Err(Box::new( io::Error::new( io::ErrorKind::Other, "mpris:artUrl is not a string." ) )) } } ================================================ FILE: src/regexes.rs ================================================ use lazy_static::lazy_static; use regex::Regex; lazy_static! { pub static ref JSONC_COMMENT_RE: Regex = Regex::new(r#"(?m)//.*$|/\*[\s\S]*?\*/"#).unwrap(); pub static ref FORWARD_RE: Regex = Regex::new(r"forward\((-?\d+)\)").unwrap(); pub static ref BACKWARD_RE: Regex = Regex::new(r"backward\((-?\d+)\)").unwrap(); pub static ref POSITION_RE: Regex = Regex::new(r"^position\(\d+\)$").unwrap(); pub static ref VOLUME_RE: Regex = Regex::new(r"volume\(([-+]?\d+)\)").unwrap(); pub static ref VAR_RE: Regex = Regex::new(r"var\((\$\w[\w-]*),\s*(\$\w[\w-]*)\)").unwrap(); pub static ref VAR_TOGGLE_RE: Regex = Regex::new(r"toggle\((\$\w[-\w]*),\s*(\$\w[-\w]*),\s*(\$\w[-\w]*)\)").unwrap(); pub static ref VAR_SET_RE: Regex = Regex::new(r"set\((\$\w[-\w]*),\s*(\$\w[-\w]*)\)").unwrap(); pub static ref GET_META_RE: Regex = Regex::new(r"get_meta\((.*?)\)").unwrap(); pub static ref LOWER_RE: Regex = Regex::new(r"lower\(\s*([\w$-]+(?:\s+[\w$-]+)*)\s*\)").unwrap(); pub static ref UPPER_RE: Regex = Regex::new(r"upper\(\s*([\w$-]+(?:\s+[\w$-]+)*)\s*\)").unwrap(); } ================================================ FILE: src/state.rs ================================================ use std::collections::HashMap; use ratatui::{layout::Rect, style::Color}; use crate::{action::Action, meta::Meta, widget::{Direction, SliderSource}}; pub struct FumState { pub meta: Meta, pub cover_art_ascii: String, pub buttons: HashMap, Option, Option)>, pub sliders: HashMap, pub vars: HashMap, pub parent_direction: Direction, pub parent_bg: Color, pub parent_fg: Color } impl FumState { pub fn new(meta: Meta) -> Self { Self { meta, cover_art_ascii: String::new(), buttons: HashMap::new(), sliders: HashMap::new(), vars: HashMap::new(), parent_direction: Direction::default(), parent_bg: Color::Reset, parent_fg: Color::Reset } } } ================================================ FILE: src/text.rs ================================================ use regex::Captures; use crate::{meta::Meta, regexes::{GET_META_RE, LOWER_RE, UPPER_RE, VAR_RE}, state::FumState, utils::widget::{format_duration, format_remaining}}; fn replace_global_var(text: &str, state: &mut FumState) -> String { match text { text if text.contains("$title") => text.replace("$title", &state.meta.title), text if text.contains("$artists") => text.replace("$artists", &state.meta.artists.join(", ")), text if text.contains("$album") => text.replace("$album", &state.meta.album), text if text.contains("$status-icon") => text.replace("$status-icon", &state.meta.status_icon.to_string()), text if text.contains("$status-text") => text.replace("$status-text", &state.meta.status_text), text if text.contains("$position-ext") => text.replace("$position-ext", &format_duration(state.meta.position, true)), text if text.contains("$position") => text.replace("$position", &format_duration(state.meta.position, false)), text if text.contains("$remaining-length-ext") => text.replace("$remaining-length-ext", &format_remaining(state.meta.position, state.meta.length, true)), text if text.contains("$remaining-length") => text.replace("$remaining-length", &format_remaining(state.meta.position, state.meta.length, false)), text if text.contains("$length-ext") => text.replace("$length-ext", &format_duration(state.meta.length, true)), text if text.contains("$length") => text.replace("$length", &format_duration(state.meta.length, false)), text if text.contains("$volume") => text.replace("$volume", &format!("{:.0}", state.meta.volume * 100.0)), _ => text.to_string() } } pub fn replace_text(text: &str, state: &mut FumState) -> String { match text { // get_meta() text action text if GET_META_RE.is_match(text) => { GET_META_RE.replace_all(text, |c: &Captures| { let key = c[1].to_string(); Meta::get_custom_meta(&state.meta.metadata, key) }).to_string() }, // var() text action text if VAR_RE.is_match(text) => { VAR_RE.replace_all(text, |c: &Captures| { let mut vars = state.vars.clone(); let name = c[1].to_string(); let default_text = c[2].to_string(); match vars.get(&name) { Some(var) => return replace_text(var, state), None => { vars.insert(name, default_text.to_string()); // Update state.vars state.vars = vars; return replace_text(&default_text, state); } } }).to_string() }, // lower() text action text if LOWER_RE.is_match(text) => { LOWER_RE.replace_all(text, |c: &Captures| { let value = c[1].to_string(); if value.starts_with("$") { replace_global_var(&value, state).to_lowercase() } else { value.to_lowercase() } }).to_string() }, // upper() text action text if UPPER_RE.is_match(text) => { UPPER_RE.replace_all(text, |c: &Captures| { let value = c[1].to_string(); if value.starts_with("$") { replace_global_var(&value, state).to_uppercase() } else { value.to_uppercase() } }).to_string() }, // global vars text if text.contains("$") => replace_global_var(text, state), _ => text.to_string() } } ================================================ FILE: src/ui.rs ================================================ use std::collections::HashMap; use ratatui::{layout::{Constraint, Layout, Margin, Position, Rect}, style::Stylize, widgets::{Block, Borders, Paragraph, Wrap}, Frame}; use crate::{action::Action, config::Config, get_border, state::FumState, utils, widget::{Direction, SliderSource}}; pub struct Ui<'a> { config: &'a Config, } impl<'a> Ui<'a> { pub fn new(config: &'a Config) -> Self { Self { config, } } pub fn click( &self, x: u16, y: u16, buttons: &'a HashMap, Option, Option)> ) -> Option<(&'a Option, &'a Option, &'a Option)> { for (_, (rect, action, action_secondary, exec)) in buttons.iter() { if rect.contains(Position::new(x, y)) { return Some(( action, action_secondary, exec )) } } None } pub fn drag( &self, start_drag: &Position, sliders: &HashMap ) -> Option<(Rect, Direction, SliderSource)> { for (_, (rect, direction, widget)) in sliders.iter() { if rect.contains(*start_drag) { return Some((*rect, direction.to_owned(), *widget)); } } None } pub fn draw(&mut self, frame: &mut Frame<'_>, state: &mut FumState) { let main_area = utils::align::get_align(frame, &self.config.align, self.config.width, self.config.height); // Terminal window is too small if &frame.area().width < &self.config.width || &frame.area().height < &self.config.height { frame.render_widget( Paragraph::new(format!( "Terminal window is too small. Must have atleast ({}x{}).", &self.config.width, &self.config.height )) .centered() .wrap(Wrap::default()) .block(Block::new().borders(Borders::ALL)), main_area ); return; } // Sets the state parents state state.parent_direction = self.config.direction.to_owned(); state.parent_bg = self.config.bg; state.parent_fg = self.config.fg; // Also pass in the cover_art_ascii on state state.cover_art_ascii = self.config.cover_art_ascii.to_owned(); let areas = Layout::default() .direction(self.config.direction.to_dir()) .flex(self.config.flex.to_flex()) .constraints( self.config.layout .iter() .map(|child| child.get_size(state)) .collect::>() ) .split(main_area); // Whether to render border let border = get_border!(&self.config.border); // Render background frame.render_widget( Block::new() .borders(border) .bg(state.parent_bg) .fg(state.parent_fg), main_area ); for (i, widget) in self.config.layout.iter().enumerate() { if let Some(area) = areas.get(i) { let [horizontal_padding, vertical_padding] = &self.config.padding; frame.render_stateful_widget(widget, area.inner(Margin::new(*horizontal_padding, *vertical_padding)), state); } } } } ================================================ FILE: src/utils/align.rs ================================================ use ratatui::{layout::{Constraint, Flex, Layout, Rect}, Frame}; use crate::config::Align; pub fn center(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(frame.area()); let [area] = Layout::vertical([Constraint::Length(height)]) .flex(Flex::Center) .areas(area); area } pub fn top(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(frame.area()); let [area] = Layout::vertical([Constraint::Length(height)]) .areas(area); area } pub fn left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area] = Layout::vertical([Constraint::Length(height)]) .flex(Flex::Center) .areas(frame.area()); let [area] = Layout::horizontal([Constraint::Length(width)]) .areas(area); area } pub fn bottom(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area] = Layout::horizontal([Constraint::Length(width)]) .flex(Flex::Center) .areas(frame.area()); let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)]) .areas(area); area } pub fn right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area] = Layout::vertical([Constraint::Length(height)]) .flex(Flex::Center) .areas(frame.area()); let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]) .areas(area); area } pub fn top_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area, _] = Layout::horizontal([Constraint::Length(width), Constraint::Min(0)]) .areas(frame.area()); let [area, _] = Layout::vertical([Constraint::Length(height), Constraint::Min(0)]) .areas(area); area } pub fn top_right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]) .areas(frame.area()); let [area, _] = Layout::vertical([Constraint::Length(height), Constraint::Min(0)]) .areas(area); area } pub fn bottom_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [area, _] = Layout::horizontal([Constraint::Length(width), Constraint::Min(0)]) .areas(frame.area()); let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)]) .areas(area); area } pub fn bottom_right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect { let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)]) .areas(frame.area()); let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)]) .areas(area); area } pub fn get_align(frame: &mut Frame<'_>, align: &Align, width: u16, height: u16) -> Rect { match align { Align::Center => center(frame, width, height), Align::Top => top(frame, width, height), Align::Left => left(frame, width, height), Align::Bottom => bottom(frame, width, height), Align::Right => right(frame, width, height), Align::TopLeft => top_left(frame, width, height), Align::TopRight => top_right(frame, width, height), Align::BottomLeft => bottom_left(frame, width, height), Align::BottomRight => bottom_right(frame, width, height) } } ================================================ FILE: src/utils/mod.rs ================================================ pub mod terminal; pub mod align; pub mod widget; ================================================ FILE: src/utils/terminal.rs ================================================ use std::io::stdout; use crossterm::{event::DisableMouseCapture, execute}; pub fn restore() { ratatui::restore(); execute!(stdout(), DisableMouseCapture) .expect("Failed to disable mouse capture."); } ================================================ FILE: src/utils/widget.rs ================================================ use std::time::Duration; use uuid::Uuid; #[macro_export] macro_rules! get_size { ($orientation:expr, $size:expr, $area:expr) => {{ let [area] = match $size { Some(width) => $orientation([Constraint::Length(*width)]).areas($area), None => $orientation([Constraint::Min(0)]).areas($area), }; area }}; } #[macro_export] macro_rules! get_color { ($bg:expr, $fg:expr, $parent_bg:expr, $parent_fg:expr) => {{ let bg = match $bg { Some(bg) => bg, None => $parent_bg, }; let fg = match $fg { Some(fg) => fg, None => $parent_fg, }; (bg, fg) }}; } #[macro_export] macro_rules! get_bold { ($bold:expr) => {{ match $bold { true => ratatui::style::Modifier::BOLD, false => ratatui::style::Modifier::default() } }}; } #[macro_export] macro_rules! get_border { ($border:expr) => {{ match $border { true => ratatui::widgets::Borders::ALL, false => ratatui::widgets::Borders::NONE } }}; } pub fn generate_id() -> String { Uuid::new_v4().to_string() } pub fn truncate(string: &str, area_size: usize) -> String { if string.chars().count() <= area_size { string.to_string() } else { // minus 3 since the dots (...) let take = if area_size > 3 { area_size - 3 } else { area_size }; let truncated: String = string.chars().take(take).collect(); format!("{}...", truncated) } } pub fn format_duration(duration: Duration, extend: bool) -> String { if duration.as_secs() >= 3600 { if extend { format!( "{:02}:{:02}:{:02}", duration.as_secs() / 3600, (duration.as_secs() % 3600) / 60, duration.as_secs() % 60 ) } else { format!( "{}:{:02}:{:02}", duration.as_secs() / 3600, (duration.as_secs() % 3600) / 60, duration.as_secs() % 60 ) } } else { if extend { format!("{:02}:{:02}", duration.as_secs() / 60, duration.as_secs() % 60) } else { format!("{}:{:02}", duration.as_secs() / 60, duration.as_secs() % 60) } } } pub fn format_remaining(current: Duration, total: Duration, extend: bool) -> String { if total > current { let remaining = total - current; format!("-{}", format_duration(remaining, extend)) } else { format!("-0:00") } } ================================================ FILE: src/widget/button.rs ================================================ use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}}; use crate::{get_bold, get_color, state::FumState, text::replace_text}; use super::FumWidget; pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Button { id, text, action, action_secondary, exec, bold, bg, fg, .. } = widget { let text = replace_text(text, state).to_string(); state.buttons.insert( id.to_string(), ( area.clone(), action.to_owned(), action_secondary.to_owned(), exec.to_owned() ) ); let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg); let bold = get_bold!(bold); // Render bg Block::new() .bg(*bg) .render(area, buf); Paragraph::new(text) .wrap(Wrap::default()) .add_modifier(bold) .fg(*fg) .render(area, buf); } } ================================================ FILE: src/widget/container.rs ================================================ use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, style::Stylize, widgets::{Block, StatefulWidget, Widget}}; use crate::{get_border, get_color, state::FumState}; use super::FumWidget; pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Container { width, height, direction, border, padding, children, flex, bg, fg } = widget { let area = Rect::new( area.x, area.y, width.unwrap_or(area.width), height.unwrap_or(area.height) ); let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg); let border = get_border!(border); Block::new() .borders(border) .bg(*bg) .fg(*fg) .render(area, buf); // Sets the state parents state state.parent_direction = direction.to_owned(); state.parent_bg = *bg; state.parent_fg = *fg; let areas = Layout::default() .direction(direction.to_dir()) .flex(flex.to_flex()) .constraints( children .iter() .map(|child| child.get_size(state)) .collect::>() ) .split(area); for (i, child) in children.iter().enumerate() { if let Some(area) = areas.get(i) { let [horizontal_padding, vertical_padding] = padding; child.render(area.inner(Margin::new(*horizontal_padding, *vertical_padding)), buf, state); } } } } ================================================ FILE: src/widget/cover_art.rs ================================================ use ratatui::{buffer::Buffer, layout::{Constraint, Flex, Layout, Rect}, style::Stylize, text::Text, widgets::{Block, StatefulWidget, Widget}}; use ratatui_image::StatefulImage; use crate::{get_border, get_color, state::FumState}; use super::FumWidget; pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::CoverArt { resize, border, bg, fg, .. } = widget { let (bg, _) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg); let border = get_border!(border); // Render bg Block::new() .borders(border) .bg(*bg) .render(area, buf); if let Some(cover_art) = state.meta.cover_art.as_mut() { StatefulWidget::render( StatefulImage::default().resize(resize.to_resize()), area, buf, &mut cover_art.image ); } else { let split = state.cover_art_ascii.split('\n'); let width = split.to_owned().map(|line| line.len() as u16).max().unwrap_or(0); let height = split.count() as u16; let [ascii_area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area); let [ascii_area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(ascii_area); Text::from(state.cover_art_ascii.as_str()) .render(ascii_area, buf); } } } ================================================ FILE: src/widget/empty.rs ================================================ use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Widget}}; use crate::{get_color, state::FumState}; use super::FumWidget; pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Empty { bg, fg, .. } = widget { let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg); Block::new() .bg(*bg) .fg(*fg) .render(area, buf); } } ================================================ FILE: src/widget/label.rs ================================================ use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}}; use crate::{get_bold, get_color, state::FumState, text::replace_text, utils}; use super::{Direction, FumWidget, LabelAlignment}; pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Label { text, direction, truncate, align, bold, bg, fg } = widget { let text = match (truncate, direction) { (true, Direction::Horizontal) => utils::widget::truncate(&replace_text(text, state), area.width.into()), (true, Direction::Vertical) => utils::widget::truncate(&replace_text(text, state), area.height.into()), _ => replace_text(text, state) }; let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg); // Whether the text is bold let bold = get_bold!(bold); let widget = match align { LabelAlignment::Left => Paragraph::new(text).left_aligned().wrap(Wrap::default()).fg(*fg).add_modifier(bold), LabelAlignment::Center => Paragraph::new(text).centered().wrap(Wrap::default()).fg(*fg).add_modifier(bold), LabelAlignment::Right => Paragraph::new(text).right_aligned().wrap(Wrap::default()).fg(*fg).add_modifier(bold), }; // Render bg Block::new() .bg(*bg) .render(area, buf); widget.render(area, buf); } } ================================================ FILE: src/widget/mod.rs ================================================ mod widget; mod container; mod cover_art; mod label; mod button; mod progress; mod volume; mod empty; pub use widget::*; ================================================ FILE: src/widget/progress.rs ================================================ use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}}; use crate::{get_color, state::FumState}; use super::{Direction, FumWidget, SliderSource}; struct Progress { progress_bar: String, empty_bar: String, progress_area: Rect, empty_area: Rect } pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Progress { id, direction, progress: prog_opt, empty: empt_opt, .. } = widget { let (prog_bg, prog_fg) = get_color!(&prog_opt.bg, &prog_opt.fg, &state.parent_bg, &state.parent_fg); let (empt_bg, empt_fg) = get_color!(&empt_opt.bg, &empt_opt.fg, &state.parent_bg, &state.parent_fg); let progress_char = prog_opt.char.to_string(); let empty_char = empt_opt.char.to_string(); state.sliders.insert( id.to_string(), (area.clone(), direction.clone(), SliderSource::Progress) ); let position = state.meta.position; let ratio = if position.as_secs() > 0 { position.as_secs() as f64 / state.meta.length.as_secs() as f64 } else { 0.0 }; let Progress { progress_bar, empty_bar, progress_area, empty_area } = match direction { Direction::Horizontal => { let filled = (ratio * area.width as f64).round(); let empty = area.width.saturating_sub(filled as u16); let progress_bar = progress_char.repeat(filled as usize); let empty_bar = empty_char.repeat(empty as usize); let [progress_area, empty_area] = Layout::horizontal([ Constraint::Length(filled as u16), Constraint::Length(empty as u16) ]).areas(area); Progress { progress_bar, empty_bar, progress_area, empty_area } }, Direction::Vertical => { let filled = (ratio * area.height as f64).round(); let empty = area.height.saturating_sub(filled as u16); let progress_bar = progress_char.repeat(filled as usize); let empty_bar = empty_char.repeat(empty as usize); let [empty_area, progress_area] = Layout::vertical([ Constraint::Length(empty as u16), Constraint::Length(filled as u16) ]).areas(area); Progress { progress_bar, empty_bar, progress_area, empty_area } } }; // Render progress bg Block::new() .bg(*prog_bg) .render(progress_area, buf); // Render progress Paragraph::new(progress_bar) .wrap(Wrap::default()) .fg(*prog_fg) .render(progress_area, buf); // Render empty bg Block::new() .bg(*empt_bg) .render(empty_area, buf); // Render empty Paragraph::new(empty_bar) .wrap(Wrap::default()) .fg(*empt_fg) .render(empty_area, buf); } } ================================================ FILE: src/widget/volume.rs ================================================ use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}}; use crate::{get_color, state::FumState}; use super::{Direction, FumWidget, SliderSource}; struct Volume { volume_bar: String, empty_bar: String, volume_area: Rect, empty_area: Rect } pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) { if let FumWidget::Volume { id, direction, volume: vol_opt, empty: empt_opt, .. } = widget { let (vol_bg, vol_fg) = get_color!(&vol_opt.bg, &vol_opt.fg, &state.parent_bg, &state.parent_fg); let (empt_bg, empt_fg) = get_color!(&empt_opt.bg, &empt_opt.fg, &state.parent_bg, &state.parent_fg); let progress_char = vol_opt.char.to_string(); let empty_char = empt_opt.char.to_string(); state.sliders.insert( id.to_string(), (area.clone(), direction.clone(), SliderSource::Volume) ); let Volume { volume_bar, empty_bar, volume_area, empty_area } = match direction { Direction::Horizontal => { let filled = (state.meta.volume * area.width as f64).round(); let empty = area.width.saturating_sub(filled as u16); let volume_bar = progress_char.repeat(filled as usize); let empty_bar = empty_char.repeat(empty as usize); let [volume_area, empty_area] = Layout::horizontal([ Constraint::Length(filled as u16), Constraint::Length(empty as u16) ]).areas(area); Volume { volume_bar, empty_bar, volume_area, empty_area } }, Direction::Vertical => { let filled = (state.meta.volume * area.height as f64).round(); let empty = area.height.saturating_sub(filled as u16); let volume_bar = progress_char.repeat(filled as usize); let empty_bar = empty_char.repeat(empty as usize); let [empty_area, volume_area] = Layout::vertical([ Constraint::Length(empty as u16), Constraint::Length(filled as u16) ]).areas(area); Volume { volume_bar, empty_bar, volume_area, empty_area } } }; // Render volume filled bg Block::new() .bg(*vol_bg) .render(volume_area, buf); // Render volume filled Paragraph::new(volume_bar) .wrap(Wrap::default()) .fg(*vol_fg) .render(volume_area, buf); // Render empty bg Block::new() .bg(*empt_bg) .render(empty_area, buf); // Render empty Paragraph::new(empty_bar) .wrap(Wrap::default()) .fg(*empt_fg) .render(empty_area, buf); } } ================================================ FILE: src/widget/widget.rs ================================================ use ratatui::{buffer::Buffer, layout::{Constraint, Rect}, style::Color, widgets::StatefulWidget}; use serde::Deserialize; use unicode_width::UnicodeWidthStr; use crate::{action::Action, state::FumState, text::replace_text, utils::widget::generate_id}; use super::{button, container, cover_art, empty, label, progress, volume}; fn default_truncate() -> bool { true } fn default_border() -> bool { false } fn default_bold() -> bool { false } fn default_padding() -> [u16; 2] { [0, 0] } #[derive(Debug, Copy, Clone)] pub enum SliderSource { Progress, Volume } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Direction { Vertical, Horizontal } impl Default for Direction { fn default() -> Self { Self::Horizontal } } impl Direction { pub fn to_dir(&self) -> ratatui::layout::Direction { match self { Self::Horizontal => ratatui::layout::Direction::Horizontal, Self::Vertical => ratatui::layout::Direction::Vertical } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum LabelAlignment { Left, Center, Right } impl Default for LabelAlignment { fn default() -> Self { Self::Left } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ContainerFlex { Start, Center, End, #[serde(rename = "space-around")] SpaceAround, #[serde(rename = "space-between")] SpaceBetween } impl Default for ContainerFlex { fn default() -> Self { ContainerFlex::Start } } impl ContainerFlex { pub fn to_flex(&self) -> ratatui::layout::Flex { match self { Self::Start => ratatui::layout::Flex::Start, Self::Center => ratatui::layout::Flex::Center, Self::End => ratatui::layout::Flex::End, Self::SpaceAround => ratatui::layout::Flex::SpaceAround, Self::SpaceBetween => ratatui::layout::Flex::SpaceBetween } } } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "lowercase")] pub enum CoverArtResize { Fit, Crop, Scale } impl Default for CoverArtResize { fn default() -> Self { Self::Scale } } impl CoverArtResize { pub fn to_resize(&self) -> ratatui_image::Resize { match self { Self::Fit => ratatui_image::Resize::Fit(Some(ratatui_image::FilterType::CatmullRom)), Self::Crop => ratatui_image::Resize::Crop(None), Self::Scale => ratatui_image::Resize::Scale(Some(ratatui_image::FilterType::CatmullRom)) } } } #[derive(Debug, Clone, Deserialize)] pub struct ProgressOption { pub char: char, pub bg: Option, pub fg: Option } #[derive(Debug, Clone, Deserialize)] pub struct VolumeOption { pub char: char, pub bg: Option, pub fg: Option } #[derive(Debug, Clone, Deserialize)] #[serde(tag = "type")] #[serde(rename_all = "lowercase")] pub enum FumWidget { Container { width: Option, height: Option, #[serde(default = "Direction::default")] direction: Direction, #[serde(default = "default_border")] border: bool, #[serde(default = "default_padding")] padding: [u16; 2], children: Vec, #[serde(default = "ContainerFlex::default")] flex: ContainerFlex, bg: Option, fg: Option }, #[serde(rename = "cover-art")] CoverArt { width: Option, height: Option, #[serde(default = "CoverArtResize::default")] resize: CoverArtResize, #[serde(default = "default_border")] border: bool, bg: Option, fg: Option }, Label { text: String, #[serde(default = "Direction::default")] direction: Direction, #[serde(default = "LabelAlignment::default")] align: LabelAlignment, #[serde(default = "default_truncate")] truncate: bool, #[serde(default = "default_bold")] bold: bool, bg: Option, fg: Option }, Button { #[serde(default = "generate_id")] id: String, text: String, action: Option, #[serde(rename = "action-secondary")] action_secondary: Option, exec: Option, #[serde(default = "Direction::default")] direction: Direction, #[serde(default = "default_bold")] bold: bool, bg: Option, fg: Option }, Progress { #[serde(default = "generate_id")] id: String, size: Option, #[serde(default = "Direction::default")] direction: Direction, progress: ProgressOption, empty: ProgressOption }, Volume { #[serde(default = "generate_id")] id: String, size: Option, #[serde(default = "Direction::default")] direction: Direction, volume: VolumeOption, empty: VolumeOption }, Empty { size: u16, bg: Option, fg: Option } } impl StatefulWidget for &FumWidget { type State = FumState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) where Self: Sized { match self { FumWidget::Container { .. } => container::render(&self, area, buf, state), FumWidget::CoverArt { .. } => cover_art::render(&self, area, buf, state), FumWidget::Label { .. } => label::render(&self, area, buf, state), FumWidget::Button { .. } => button::render(&self, area, buf, state), FumWidget::Progress { .. } => progress::render(&self, area, buf, state), FumWidget::Volume { .. } => volume::render(&self, area, buf, state), FumWidget::Empty { .. } => empty::render(&self, area, buf, state) } } } impl FumWidget { pub fn get_size(&self, state: &mut FumState) -> Constraint { match self { Self::Container { width, height, direction, .. } => { match direction { Direction::Horizontal => width.map(|w| Constraint::Length(w)).unwrap_or(Constraint::Min(0)), Direction::Vertical => height.map(|h| Constraint::Length(h)).unwrap_or(Constraint::Min(0)) } }, Self::CoverArt { width, height, .. } => { match &state.parent_direction { Direction::Horizontal => width.map(|w| Constraint::Length(w)).unwrap_or(Constraint::Min(0)), Direction::Vertical => height.map(|h| Constraint::Length(h)).unwrap_or(Constraint::Min(0)) } }, Self::Label { direction, .. } => { match direction { Direction::Horizontal => Constraint::Min(0), Direction::Vertical => Constraint::Length(1) } }, Self::Button { direction, text, .. } => { match direction { Direction::Horizontal => Constraint::Length(UnicodeWidthStr::width(replace_text(text, state).as_str()) as u16), Direction::Vertical => Constraint::Length(1) } }, Self::Progress { size, direction, .. } => { match direction { Direction::Horizontal => size.map(|s| Constraint::Length(s)).unwrap_or(Constraint::Min(0)), Direction::Vertical => Constraint::Length(1) } }, Self::Volume { size, direction, .. } => { match direction { Direction::Horizontal => size.map(|s| Constraint::Length(s)).unwrap_or(Constraint::Min(0)), Direction::Vertical => Constraint::Length(1) } }, Self::Empty { size, .. } => Constraint::Length(*size) } } }