Full Code of qxb3/fum for AI

main a74d5f169b1c cached
64 files
142.8 KB
37.0k tokens
117 symbols
1 requests
Download .txt
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 <qxbthree@gmail.com>"
--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
================================================
<h3 align="center">
  <img src="https://raw.githubusercontent.com/qxb3/fum/refs/heads/main/repo/logo.png" width="200"/>
</h3>

<h2 align="center">
  fum: A fully customizable tui-based mpris music client.
</h2>

<p align="center">
  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.
</p>

# ❗❗ 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.


<p align="center">
  <a href="https://discord.gg/UfXMeyZ6Zt">
    <img src="https://img.shields.io/discord/1331325131649454184?style=for-the-badge&logo=discord&logoColor=%23ffffff&label=discord&labelColor=1C1B22&color=DEFEDF" />
  </a>

  <a href="https://github.com/qxb3/fum/blob/main/LICENSE">
    <img src="https://img.shields.io/badge/MIT-DEFEDF?style=for-the-badge&logo=Pinboard&label=License&labelColor=1C1B22" />
  </a>

  <a href="https://github.com/qxb3/fum/stargazers">
    <img src="https://img.shields.io/github/stars/qxb3/fum?style=for-the-badge&logo=Apache%20Spark&logoColor=ffffff&labelColor=1C1B22&color=DEFEDF" />
  </a>
</p>

## Demo

<img
  width="800px"
  src="https://github.com/user-attachments/assets/930283d8-6299-4ef9-865b-26960dcee866"
/>

## 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

<img src="https://github.com/qxb3/fum/blob/main/repo/showcase.png" />

## 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.

<br>

[![Packaging status](https://repology.org/badge/vertical-allrepos/fum.svg)](https://repology.org/project/fum/versions)

<br>

### 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.
<br>

- 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.
<br>

- 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

<details>
  <summary>Keys List</summary>

  - `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`
  - `{}[]|;:'"&#96;,.<>/?` - `keyboard symbols`
</details>

<br>

- Type: `Object`
- Default:

```jsonc
{
  "esc;q": "quit()",
  "h": "prev()",
  "l": "next()",
  " ": "play_pause()"
}
```

#### * `align` (Optional)

\- Where in the terminal fum will be positioned in.
<br>

- Type: `string`
- Values:
  - `left`
  - `top`
  - `right`
  - `bottom`
  - `center`
  - `top-left`
  - `top-right`
  - `bottom-left`
  - `bottom-right`
- Default: `center`

#### * `direction` (Optional)

\- See [#Direction](#direction).
<br>

- Type: `string`
- Default: `horizontal`

#### * `flex` (Optional)

\- See [#ContainerFlex](#containerflex).
<br>

- Type: `string`
- Default: `start`

#### * `width` (Optional)

\- The allocated width.
<br>

- Type: `number`
- Default: `19`

#### * `height` (Optional)

\- The allocated height.
<br>

- Type: `number`
- Default: `15`

#### * `border` (Optional)

\- Whether to render a border around.
<br>

- Type: `boolean`
- Default: `false`

#### * `padding` (Optional)

\- Whether to add horizontal,vertical padding.
<br>

- 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.
<br>

- Type: `string`
- Default: `""`

#### * `layout` (Optional)

\- Layout ui to be rendered. See [#Widgets](#widgets) For all the available widgets.
<br>

- Type: `widget[]`
- Default: [#Example Full Config](#example-full-config)

---

### Widgets

List of available widgets.

#### `Container`

\- A container containing widgets.
<br>

- 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.

<br>

- 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.
<br>

- 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).

<br>

- Example:

```jsonc
{
  "type": "cover-art",
  "width": 20,
  "height": 20,
  "border": false,
  "resize": "scale",
  "bg": "red",
  "fg": "#000000"
}
```

#### `Label`

\- Displays a text label.
<br>

- 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).

<br>

- 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.
<br>

- 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).

<br>

- Example:

```jsonc
{
  "type": "button",
  "text": "$status_icon",
  "action": "play_pause()",
  "exec": "echo hi",
  "bold": false,
  "bg": "reset",
  "fg": "magenta"
}
```

#### `Progress`

\- Displays a progress bar.
<br>

- 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).

<br>

- 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.
<br>

- 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).

<br>

- 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.
<br>

- Fields:
  - `size`: `number` (optional). Specifies the width of the empty space. See [#Width & Height](#width--height).

<br>

- 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.
<br>

- Values:
  - `horizontal`
  - `vertical`
- Default: `horizontal`

#### LabelAlignment

\- Specifies the alignment of text within a label.
<br>

- Values:
  - `left`
  - `center`
  - `right`
Default: `left`

#### ContainerFlex

\- Defines how space is distributed among items in a container.
<br>

- 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).
<br>

- 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)

<details>
<summary>Layout Config</summary>

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()" }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```
</details>

### * qxb3

![preview](/rices/preconfig_06.png)

<details>
<summary>Layout Config</summary>

```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]" }
          ]
        }
      ]
    }
  ]
}
```

</details>

### * qxb3

![preview](/rices/preconfig_05.png)

<details>
<summary>Layout Config</summary>

```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()" }
      ]
    }
  ]
}
```

</details>

### * qxb3

![preview](/rices/preconfig_04.png)

<details>
<summary>Layout Config</summary>

```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" } }
  ]
}
```

</details>

### * qxb3

![preview](/rices/preconfig_03.png)

<details>
<summary>Layout Config</summary>

```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": "<" } }
  ]
}
```

</details>

### * qxb3

![preview](/rices/preconfig_02.png)

<details>
<summary>Layout Config</summary>

```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)" }
          ]
        }
      ]
    }
  ]
}
```

</details>

### * qxb3

![preview](/rices/preconfig_01.png)

<details>
<summary>Layout Config</summary>

```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()" }
          ]
        }
      ]
    }
  ]
}
```

</details>


================================================
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
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%sveltekit.assets%/favicon.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" integrity="sha512-Evv84Mr4kqVGRNSgIGL/F/aIDqQb7xQ2vcrdIwxfjThSH8CSR7PBEakCr51Ck+w+/U6swU2Im1vVX0SVk9ABhg==" crossorigin="anonymous" referrerpolicy="no-referrer" />

    <link href="https://fonts.googleapis.com/css2?family=Jersey+15&display=swap" rel="stylesheet">

    %sveltekit.head%
  </head>

  <body data-sveltekit-preload-data="hover">
    %sveltekit.body%
  </body>
</html>


================================================
FILE: doc-site/src/global.d.ts
================================================
/// <reference types="@sveltejs/kit" />

declare module '*.md' {
  import type { SvelteComponent } from 'svelte'

  export default class Comp extends SvelteComponent{}

  export const metadata: Record<string, unknown>
}


================================================
FILE: doc-site/src/lib/Nav.svelte
================================================
<script lang="ts">
  import { sideBarStore } from '$lib/stores'

  function toggleSideBar(e: MouseEvent) {
    e.stopPropagation()

    sideBarStore.update(state => !state)
  }
</script>

<nav class="px-4 py-2 bg-background text-fg border-b border-fg">
  <div class="grid grid-cols-3 text-2xl">
    <div class="flex items-center justify-start space-x-4">
      <button
        on:click={toggleSideBar}
        class="cursor-pointer transition-colors duration-300 hover:text-primary"
        aria-label="sidebar button">
        <i class="fa-solid fa-bars"></i>
      </button>
    </div>

    <a
      class="text-center hover:text-primary transition-colors duration-300"
      href="/">
      <h1 class="font-bold">fum documentation</h1>
    </a>

    <div class="list-none flex items-center justify-end space-x-4">
      <a
        class="text-background text-lg px-2 bg-fg hover:bg-primary transition-colors duration-300"
        href="https://github.com/qxb3/fum/releases/tag/v{DOC_VERSION}"
        target="_blank"
        aria-label="doc version">
        v{DOC_VERSION}
      </a>

      <a
        class="hover:text-primary transition-colors duration-300"
        href="https://discord.gg/UfXMeyZ6Zt"
        target="_blank"
        aria-label="discord invite">
        <i class="fa-brands fa-discord"></i>
      </a>

      <a
        class="hover:text-primary transition-colors duration-300"
        href="https://github.com/qxb3/fum"
        target="_blank"
        aria-label="github link">
        <i class="fa-brands fa-github"></i>
      </a>
    </div>
  </div>
</nav>


================================================
FILE: doc-site/src/lib/SideBar.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte'
  import { sideBarStore } from '$lib/stores'

  let sideBar: HTMLDivElement;

  function closeSideBar() {
    sideBarStore.set(false)
  }

  onMount(() => {
    window.addEventListener('click', (e) => {
      if ($sideBarStore && (sideBar && !sideBar.contains(e.target as Node))) {
        sideBarStore.set(false)
      }
    })
  })
</script>

<div
  bind:this={sideBar}
  class="fixed top-0 w-96 h-screen border-r border-r-fg z-50 bg-background transition-all duration-300"
  class:left-0={$sideBarStore}
  class:-left-96={!$sideBarStore}>
  <div class="relative">
    <button
      class="absolute top-0 right-0 m-4 cursor-pointer text-md"
      aria-label="close sidebar"
      on:click={closeSideBar}>
      <i class="fa-solid fa-xmark"></i>
    </button>
  </div>

  <ul class="h-full list-none mt-4 text-xl space-y-2 px-4">
    {#each DOCS as doc}
      <li>
        <a
          class="hover:text-primary"
          href={doc.url}
          on:click={closeSideBar}>
          {doc.title}
        </a>
      </li>
    {/each}
  </ul>
</div>


================================================
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
================================================
<script lang="ts">
  import '../app.css'

  import Nav from '$lib/Nav.svelte'
  import SideBar from '$lib/SideBar.svelte'

  const { children } = $props()
</script>


<SideBar />

<div class="h-full">
  <Nav />

  {@render children()}
</div>


================================================
FILE: doc-site/src/routes/+page.svelte
================================================
<div class="h-full max-w-[64rem] mx-auto">
  <div class="pt-16 px-8 space-y-12 text-center">
    <div class="flex justify-center">
      <img
        width="300px"
        src="/logo.png"
        alt="Logo"
      />
    </div>

    <h1 class="text-5xl font-bold">
      fum - A fully ricable tui-based mpris music client.
    </h1>

    <a
      class="font-bold border border-fg text-xl px-4 py-2 cursor-pointer hover:bg-primary hover:text-background hover:border-primary duration-300 transition-colors"
      href="/docs/getting_started">
      <span>Get Started</span>
      <span>
        <i class="fa-solid fa-arrow-right"></i>
      </span>
    </a>
  </div>
</div>


================================================
FILE: doc-site/src/routes/docs/[title]/+page.svelte
================================================
<script lang="ts">
  import type { PageProps } from './$types'

  const { data }: PageProps = $props()
</script>

<svelte:head>
  <title>{data.doc.title}</title>
</svelte:head>

<main class="doc p-8">
  <div class="overflow-x-hidden">
    {@html data.doc.html}
  </div>

  <hr>

  <div class="flex justify-between items-center">
    {#if data.doc.prev}
      <a
        class="self-start font-bold text-fg! text-4xl cursor-pointer hover:text-background duration-300 transition-colors"
        href={data.doc.prev.url}>
        <span>
          <i class="fa-solid fa-chevron-left"></i>
        </span>
        <span>{data.doc.prev.title}</span>
      </a>
    {:else}
      <div></div>
    {/if}

    {#if data.doc.next}
      <a
        class="font-bold text-fg! text-4xl cursor-pointer hover:text-background duration-300 transition-colors"
        href={data.doc.next.url}>
        <span>{data.doc.next.title}</span>
        <span>
          <i class="fa-solid fa-chevron-right"></i>
        </span>
      </a>
    {/if}
  </div>
</main>


================================================
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
================================================
/// <reference types="vite/client" />

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(/({|&#123;)DOC_VERSION(}|&#125;)/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<D>(deserializer: D) -> Result<Self, D::Error>
    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::<i64>() {
                        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::<i64>() {
                        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::<u64>() {
                        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::<f64>()
                                .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::<f64>()
                                .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::<f64>()
                                .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<String>,

    #[arg(short, long, value_name = "string[]", value_delimiter = ',')]
    players: Option<Vec<String>>,

    #[arg(long, value_name = "boolean")]
    use_active_player: Option<bool>,

    #[arg(long, value_name = "number")]
    fps: Option<u64>,

    #[arg(short, long, value_name = "center,top,left,bottom,right,top-left,top-right,bottom-left,bottom-right")]
    align: Option<String>,

    #[command(subcommand)]
    pub command: Option<Commands>
}

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<Self> {
        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<String>,

    #[serde(default = "use_active_player")]
    pub use_active_player: bool,

    #[serde(default = "fps")]
    pub fps: u64,

    #[serde(default = "keybinds")]
    pub keybinds: HashMap<Keybind, Action>,

    #[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<FumWidget>,
}

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<Self> {
        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<String> { 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<Keybind, Action> {
    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<FumWidget> {
    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<Keybind>),
    WithModifier(KeyModifiers, Box<Keybind>)
}

impl<'de> Deserialize<'de> for Keybind {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    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::<Result<Vec<Keybind>, 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<D>(keybind: &str) -> Result<Keybind, D>
    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::<u8>() {
                    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::<Vec<&str>>();
                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<T> = std::result::Result<T, Box<dyn error::Error>>;

pub struct Fum<'a> {
    config: &'a Config,
    pub terminal: Terminal<CrosstermBackend<Stdout>>,
    pub ui: Ui<'a>,
    pub picker: Picker,
    pub player: Option<Player>,
    pub state: FumState,

    // drag state
    pub dragging: bool,
    pub start_drag: Option<Position>,
    pub current_drag: Option<Position>,
    pub drag_action: Option<Action>,

    pub redraw: bool,
    pub exit: bool
}

impl<'a> Fum<'a> {
    pub fn new(config: &'a Config) -> FumResult<Self> {
        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<TrackID>,
    pub title: String,
    pub artists: Vec<String>,
    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<CoverArt>,
    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<Self> {
        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) = &current {
            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<Player> {
        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<Metadata> {
        let metadata = player.get_metadata()?;
        Ok(metadata)
    }

    pub fn get_trackid(metadata: &Metadata) -> FumResult<TrackID> {
        let trackid = metadata.track_id()
            .ok_or("Failed to get track_id")?;

        Ok(trackid)
    }

    pub fn get_title(metadata: &Metadata) -> FumResult<String> {
        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<Vec<String>> {
        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<PlaybackStatus> {
        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<Duration> {
        let position = player.get_position()
            .map_err(|err| format!("Failed to get player position: {err}"))?;

        Ok(position)
    }

    pub fn get_length(metadata: &Metadata) -> FumResult<Duration> {
        let length = metadata
            .length()
            .ok_or("Failed to get mpris:length".to_string())?;

        Ok(length)
    }

    pub fn get_album(metadata: &Metadata) -> FumResult<String> {
        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<f64> {
        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<CoverArt> {
        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) = &current {
                if let Some(current_art) = &current.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<String, (Rect, Option<Action>, Option<Action>, Option<String>)>,
    pub sliders: HashMap<String, (Rect, Direction, SliderSource)>,
    pub vars: HashMap<String, String>,
    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<String, (Rect, Option<Action>, Option<Action>, Option<String>)>
    ) -> Option<(&'a Option<Action>, &'a Option<Action>, &'a Option<String>)> {
        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<String, (Rect, Direction, SliderSource)>
    ) -> 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::<Vec<Constraint>>()
            )
            .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::<Vec<Constraint>>()
            )
            .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<Color>,
    pub fg: Option<Color>
}

#[derive(Debug, Clone, Deserialize)]
pub struct VolumeOption {
    pub char: char,
    pub bg: Option<Color>,
    pub fg: Option<Color>
}

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
#[serde(rename_all = "lowercase")]
pub enum FumWidget {
    Container {
        width: Option<u16>,
        height: Option<u16>,
        #[serde(default = "Direction::default")]
        direction: Direction,
        #[serde(default = "default_border")]
        border: bool,
        #[serde(default = "default_padding")]
        padding: [u16; 2],
        children: Vec<FumWidget>,
        #[serde(default = "ContainerFlex::default")]
        flex: ContainerFlex,
        bg: Option<Color>,
        fg: Option<Color>
    },

    #[serde(rename = "cover-art")]
    CoverArt {
        width: Option<u16>,
        height: Option<u16>,
        #[serde(default = "CoverArtResize::default")]
        resize: CoverArtResize,
        #[serde(default = "default_border")]
        border: bool,
        bg: Option<Color>,
        fg: Option<Color>
    },

    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<Color>,
        fg: Option<Color>
    },

    Button {
        #[serde(default = "generate_id")]
        id: String,
        text: String,
        action: Option<Action>,
        #[serde(rename = "action-secondary")]
        action_secondary: Option<Action>,
        exec: Option<String>,
        #[serde(default = "Direction::default")]
        direction: Direction,
        #[serde(default = "default_bold")]
        bold: bool,
        bg: Option<Color>,
        fg: Option<Color>
    },

    Progress {
        #[serde(default = "generate_id")]
        id: String,
        size: Option<u16>,
        #[serde(default = "Direction::default")]
        direction: Direction,
        progress: ProgressOption,
        empty: ProgressOption
    },

    Volume {
        #[serde(default = "generate_id")]
        id: String,
        size: Option<u16>,
        #[serde(default = "Direction::default")]
        direction: Direction,
        volume: VolumeOption,
        empty: VolumeOption
    },

    Empty {
        size: u16,
        bg: Option<Color>,
        fg: Option<Color>
    }
}

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)
        }
    }
}
Download .txt
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
Download .txt
SYMBOL INDEX (117 symbols across 24 files)

FILE: doc-site/src/global.d.ts
  class Comp (line 6) | class Comp extends SvelteComponent{}

FILE: doc-site/vite.config.ts
  constant DOC_VERSION (line 15) | const DOC_VERSION = fs.readFileSync('DOC_VERSION', 'utf-8').trim()
  constant DOCS (line 16) | const DOCS = await getDocs('docs-content')
  type addCodeblockCopyButtonOptions (line 29) | interface addCodeblockCopyButtonOptions {
  function addCodeblockCopyButton (line 33) | function addCodeblockCopyButton ({
  function getDocs (line 73) | async function getDocs(docsPath: string) {

FILE: src/action.rs
  type VolumeType (line 17) | pub enum VolumeType {
  type Action (line 24) | pub enum Action {
    method deserialize (line 55) | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    method run (line 185) | pub fn run(action: &Action, fum: &mut Fum) -> FumResult<()> {

FILE: src/cli.rs
  type Commands (line 7) | pub enum Commands {
  type FumCli (line 13) | pub struct FumCli {
  function run (line 33) | pub fn run() -> FumResult<(FumCli, Config)> {

FILE: src/config/config.rs
  type Align (line 12) | pub enum Align {
    method from_str (line 29) | pub fn from_str(str: &str) -> Option<Self> {
  type Config (line 45) | pub struct Config {
    method load (line 115) | pub fn load(path: &PathBuf) -> FumResult<Self> {
  method default (line 93) | fn default() -> Self {

FILE: src/config/defaults.rs
  function players (line 9) | pub fn players() -> Vec<String> { vec!["spotify".to_string()] }
  function use_active_player (line 10) | pub fn use_active_player() -> bool { false }
  function fps (line 11) | pub fn fps() -> u64 { 10 }
  function align (line 13) | pub fn align() -> Align { Align::Center }
  function direction (line 14) | pub fn direction() -> Direction { Direction::Vertical }
  function flex (line 15) | pub fn flex() -> ContainerFlex { ContainerFlex::Start }
  function width (line 17) | pub fn width() -> u16 { 19 }
  function height (line 18) | pub fn height() -> u16 { 15 }
  function border (line 20) | pub fn border() -> bool { false }
  function padding (line 22) | pub fn padding() -> [u16; 2] { [0, 0] }
  function bg (line 24) | pub fn bg() -> Color { Color::Reset }
  function fg (line 25) | pub fn fg() -> Color { Color::Reset }
  function cover_art_ascii (line 27) | pub fn cover_art_ascii() -> String { "".to_string() }
  function keybinds (line 29) | pub fn keybinds() -> HashMap<Keybind, Action> {
  function layout (line 38) | pub fn layout() -> Vec<FumWidget> {

FILE: src/config/keybind.rs
  type Keybind (line 5) | pub enum Keybind {
    method deserialize (line 28) | fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    method into_keycode (line 49) | pub fn into_keycode(&self) -> KeyCode {
    method parse_keybind (line 72) | fn parse_keybind<D>(keybind: &str) -> Result<Keybind, D>

FILE: src/fum.rs
  type FumResult (line 11) | pub type FumResult<T> = std::result::Result<T, Box<dyn error::Error>>;
  type Fum (line 13) | pub struct Fum<'a> {
  function new (line 32) | pub fn new(config: &'a Config) -> FumResult<Self> {
  function run (line 64) | pub fn run(&mut self) -> FumResult<()> {
  function term_events (line 82) | fn term_events(&mut self) -> FumResult<()> {
  function update_meta (line 216) | fn update_meta(&mut self) {

FILE: src/main.rs
  function main (line 16) | fn main() -> FumResult<()> {

FILE: src/meta.rs
  type CoverArt (line 12) | pub struct CoverArt {
  type Meta (line 18) | pub struct Meta {
    method fetch (line 55) | pub fn fetch(player: &Player, picker: &Picker, current: Option<&Self>)...
    method get_player (line 100) | pub fn get_player(config: &Config) -> FumResult<Player> {
    method get_metadata (line 136) | pub fn get_metadata(player: &Player) -> FumResult<Metadata> {
    method get_trackid (line 141) | pub fn get_trackid(metadata: &Metadata) -> FumResult<TrackID> {
    method get_title (line 148) | pub fn get_title(metadata: &Metadata) -> FumResult<String> {
    method get_artists (line 157) | pub fn get_artists(metadata: &Metadata) -> FumResult<Vec<String>> {
    method get_status (line 166) | pub fn get_status(player: &Player) -> FumResult<PlaybackStatus> {
    method get_status_icon (line 174) | pub fn get_status_icon(status: &PlaybackStatus) -> char {
    method get_status_text (line 182) | pub fn get_status_text(status: &PlaybackStatus) -> String {
    method get_position (line 190) | pub fn get_position(player: &Player) -> FumResult<Duration> {
    method get_length (line 197) | pub fn get_length(metadata: &Metadata) -> FumResult<Duration> {
    method get_album (line 205) | pub fn get_album(metadata: &Metadata) -> FumResult<String> {
    method get_volume (line 214) | pub fn get_volume(player: &Player) -> FumResult<f64> {
    method get_custom_meta (line 221) | pub fn get_custom_meta(metadata: &Metadata, key: String) -> String {
    method get_cover_art (line 246) | pub fn get_cover_art(metadata: &Metadata, picker: &Picker, current: Op...
  method default (line 35) | fn default() -> Self {

FILE: src/state.rs
  type FumState (line 7) | pub struct FumState {
    method new (line 19) | pub fn new(meta: Meta) -> Self {

FILE: src/text.rs
  function replace_global_var (line 5) | fn replace_global_var(text: &str, state: &mut FumState) -> String {
  function replace_text (line 28) | pub fn replace_text(text: &str, state: &mut FumState) -> String {

FILE: src/ui.rs
  type Ui (line 7) | pub struct Ui<'a> {
  function new (line 12) | pub fn new(config: &'a Config) -> Self {
  function click (line 18) | pub fn click(
  function drag (line 37) | pub fn drag(
  function draw (line 51) | pub fn draw(&mut self, frame: &mut Frame<'_>, state: &mut FumState) {

FILE: src/utils/align.rs
  function center (line 5) | pub fn center(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function top (line 17) | pub fn top(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function left (line 28) | pub fn left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function bottom (line 39) | pub fn bottom(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function right (line 50) | pub fn right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function top_left (line 61) | pub fn top_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function top_right (line 71) | pub fn top_right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {
  function bottom_left (line 81) | pub fn bottom_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Re...
  function bottom_right (line 91) | pub fn bottom_right(frame: &mut Frame<'_>, width: u16, height: u16) -> R...
  function get_align (line 101) | pub fn get_align(frame: &mut Frame<'_>, align: &Align, width: u16, heigh...

FILE: src/utils/terminal.rs
  function restore (line 5) | pub fn restore() {

FILE: src/utils/widget.rs
  function generate_id (line 53) | pub fn generate_id() -> String {
  function truncate (line 57) | pub fn truncate(string: &str, area_size: usize) -> String {
  function format_duration (line 68) | pub fn format_duration(duration: Duration, extend: bool) -> String {
  function format_remaining (line 94) | pub fn format_remaining(current: Duration, total: Duration, extend: bool...

FILE: src/widget/button.rs
  function render (line 7) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/container.rs
  function render (line 7) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/cover_art.rs
  function render (line 8) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/empty.rs
  function render (line 7) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/label.rs
  function render (line 7) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/progress.rs
  type Progress (line 7) | struct Progress {
  function render (line 14) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/volume.rs
  type Volume (line 7) | struct Volume {
  function render (line 14) | pub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &...

FILE: src/widget/widget.rs
  function default_truncate (line 8) | fn default_truncate() -> bool { true }
  function default_border (line 9) | fn default_border() -> bool { false }
  function default_bold (line 10) | fn default_bold() -> bool { false }
  function default_padding (line 11) | fn default_padding() -> [u16; 2] { [0, 0] }
  type SliderSource (line 14) | pub enum SliderSource {
  type Direction (line 21) | pub enum Direction {
    method to_dir (line 33) | pub fn to_dir(&self) -> ratatui::layout::Direction {
  method default (line 27) | fn default() -> Self {
  type LabelAlignment (line 43) | pub enum LabelAlignment {
  method default (line 50) | fn default() -> Self {
  type ContainerFlex (line 57) | pub enum ContainerFlex {
    method to_flex (line 74) | pub fn to_flex(&self) -> ratatui::layout::Flex {
  method default (line 68) | fn default() -> Self {
  type CoverArtResize (line 87) | pub enum CoverArtResize {
    method to_resize (line 100) | pub fn to_resize(&self) -> ratatui_image::Resize {
  method default (line 94) | fn default() -> Self {
  type ProgressOption (line 110) | pub struct ProgressOption {
  type VolumeOption (line 117) | pub struct VolumeOption {
  type FumWidget (line 126) | pub enum FumWidget {
    method get_size (line 232) | pub fn get_size(&self, state: &mut FumState) -> Constraint {
  type State (line 213) | type State = FumState;
  method render (line 215) | fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)
Condensed preview — 64 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (157K chars).
[
  {
    "path": ".fpm",
    "chars": 197,
    "preview": "-s dir\n--name fum\n--description \"A tui-based mpris music client.\"\n--license MIT\n--maintainer \"qxb3 <qxbthree@gmail.com>\""
  },
  {
    "path": ".github/workflows/release.yml",
    "chars": 1688,
    "preview": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: C"
  },
  {
    "path": ".gitignore",
    "chars": 8,
    "preview": "/target\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 738,
    "preview": "# Contribution Guide\n\nThank you for your interest in contributing! Please follow these guidelines to keep the project cl"
  },
  {
    "path": "Cargo.toml",
    "chars": 906,
    "preview": "[package]\nname = \"fum-player\"\ndescription = \"A tui-based mpris music client.\"\nversion = \"1.3.1\"\nrepository = \"https://gi"
  },
  {
    "path": "LICENSE",
    "chars": 1061,
    "preview": "MIT License\n\nCopyright (c) 2025 qxb3\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof th"
  },
  {
    "path": "README.md",
    "chars": 2132,
    "preview": "<h3 align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/qxb3/fum/refs/heads/main/repo/logo.png\" width=\"200\"/>\n"
  },
  {
    "path": "doc-site/.gitignore",
    "chars": 210,
    "preview": "node_modules\n\n# Output\n.output\n.vercel\n.netlify\n.wrangler\n/.svelte-kit\n/build\n\n# OS\n.DS_Store\nThumbs.db\n\n# Env\n.env\n.env"
  },
  {
    "path": "doc-site/.npmrc",
    "chars": 19,
    "preview": "engine-strict=true\n"
  },
  {
    "path": "doc-site/DOC_VERSION",
    "chars": 6,
    "preview": "1.3.0\n"
  },
  {
    "path": "doc-site/README.md",
    "chars": 41,
    "preview": "# docs-site\n\nDocumentation site for fum.\n"
  },
  {
    "path": "doc-site/docs-content/01_getting_started/doc.md",
    "chars": 1852,
    "preview": "---\ntitle: Getting Started\nnext: /docs/configuring:Configuring\n---\n\n# Getting Started\n\n---\n\n## Installation\n\nTo get star"
  },
  {
    "path": "doc-site/docs-content/02_configuring/doc.md",
    "chars": 16647,
    "preview": "---\ntitle: Configuring\nprev: /docs/getting_started:Getting Started\nnext: /docs/faq:FAQ\n---\n\n# Configuring\n\nThis entire s"
  },
  {
    "path": "doc-site/docs-content/03_faq/doc.md",
    "chars": 707,
    "preview": "---\ntitle: FAQ\nprev: /docs/configuring:Configuring\nnext: /docs/compability:Compability\n---\n\n# FAQ\n\n---\n\n## Why is there "
  },
  {
    "path": "doc-site/docs-content/04_compability/doc.md",
    "chars": 326,
    "preview": "---\ntitle: Compability\nprev: /docs/faq:FAQ\nnext: /docs/rices:Rices\n---\n\n# Compability\n\n---\n\nSome terminals will have som"
  },
  {
    "path": "doc-site/docs-content/05_rices/doc.md",
    "chars": 12924,
    "preview": "---\ntitle: Rices\nprev: /docs/compability:Compability\n---\n\n# Rices\n\nCompilation of rices / customization of fum.\n\n---\n\n##"
  },
  {
    "path": "doc-site/package.json",
    "chars": 957,
    "preview": "{\n  \"name\": \"fum-doc-site\",\n  \"private\": true,\n  \"version\": \"0.0.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite"
  },
  {
    "path": "doc-site/src/app.css",
    "chars": 2165,
    "preview": "@import 'tailwindcss';\n\n@theme {\n  --font-embed-code:  \"Jersey 15\", serif;\n  --color-background: #1c1b22;\n  --color-fg: "
  },
  {
    "path": "doc-site/src/app.d.ts",
    "chars": 274,
    "preview": "// See https://svelte.dev/docs/kit/types#app.d.ts\n// for information about these interfaces\ndeclare global {\n\tnamespace "
  },
  {
    "path": "doc-site/src/app.html",
    "chars": 698,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%sveltekit.assets%/fav"
  },
  {
    "path": "doc-site/src/global.d.ts",
    "chars": 220,
    "preview": "/// <reference types=\"@sveltejs/kit\" />\n\ndeclare module '*.md' {\n  import type { SvelteComponent } from 'svelte'\n\n  expo"
  },
  {
    "path": "doc-site/src/lib/Nav.svelte",
    "chars": 1585,
    "preview": "<script lang=\"ts\">\n  import { sideBarStore } from '$lib/stores'\n\n  function toggleSideBar(e: MouseEvent) {\n    e.stopPro"
  },
  {
    "path": "doc-site/src/lib/SideBar.svelte",
    "chars": 1102,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte'\n  import { sideBarStore } from '$lib/stores'\n\n  let sideBar: HTMLD"
  },
  {
    "path": "doc-site/src/lib/index.ts",
    "chars": 100,
    "preview": "export { default as Nav } from './Nav.svelte'\nexport { default as SideBar } from './SideBar.svelte'\n"
  },
  {
    "path": "doc-site/src/lib/stores.ts",
    "chars": 85,
    "preview": "import { writable } from 'svelte/store'\n\nexport const sideBarStore = writable(false)\n"
  },
  {
    "path": "doc-site/src/routes/+layout.js",
    "chars": 30,
    "preview": "export const prerender = true\n"
  },
  {
    "path": "doc-site/src/routes/+layout.svelte",
    "chars": 242,
    "preview": "<script lang=\"ts\">\n  import '../app.css'\n\n  import Nav from '$lib/Nav.svelte'\n  import SideBar from '$lib/SideBar.svelte"
  },
  {
    "path": "doc-site/src/routes/+page.svelte",
    "chars": 672,
    "preview": "<div class=\"h-full max-w-[64rem] mx-auto\">\n  <div class=\"pt-16 px-8 space-y-12 text-center\">\n    <div class=\"flex justif"
  },
  {
    "path": "doc-site/src/routes/docs/[title]/+page.svelte",
    "chars": 1039,
    "preview": "<script lang=\"ts\">\n  import type { PageProps } from './$types'\n\n  const { data }: PageProps = $props()\n</script>\n\n<svelt"
  },
  {
    "path": "doc-site/src/routes/docs/[title]/+page.ts",
    "chars": 527,
    "preview": "import type { PageLoad, EntryGenerator } from './$types'\n\nimport { error } from '@sveltejs/kit'\n\nexport const load: Page"
  },
  {
    "path": "doc-site/src/vite-env.d.ts",
    "chars": 271,
    "preview": "/// <reference types=\"vite/client\" />\n\ndeclare const DOC_VERSION; string\ndeclare const DOCS: {\n  url: string\n  path: str"
  },
  {
    "path": "doc-site/svelte.config.js",
    "chars": 687,
    "preview": "import { mdsvex } from 'mdsvex'\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\nimport staticAdapter from"
  },
  {
    "path": "doc-site/tailwind.config.js",
    "chars": 314,
    "preview": "export default {\n  content: ['./src/**/*.{svelte,ts,js}'],\n  theme: {\n    extend: {\n      colors: {\n        background: "
  },
  {
    "path": "doc-site/tsconfig.json",
    "chars": 391,
    "preview": "{\n  \"extends\": \"./.svelte-kit/tsconfig.json\",\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"esMo"
  },
  {
    "path": "doc-site/vite.config.ts",
    "chars": 3751,
    "preview": "import tailwindcss from '@tailwindcss/vite'\n\nimport { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from"
  },
  {
    "path": "flake.nix",
    "chars": 2284,
    "preview": "{\n  description = \"Fum: A fully ricable tui-based music client\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/n"
  },
  {
    "path": "nix/default.nix",
    "chars": 98,
    "preview": "{\n  nixosModules = import ./modules/default.nix;\n  homeManagerModules = import ./hm-module.nix;\n}\n"
  },
  {
    "path": "nix/hm-module.nix",
    "chars": 2437,
    "preview": "{\n  config,\n  lib,\n  fumPackage,\n  ...\n}: let\n  inherit (lib.types) bool package int str;\n  inherit (lib.modules) mkIf;\n"
  },
  {
    "path": "src/action.rs",
    "chars": 11078,
    "preview": "use std::time::Duration;\n\nuse mpris::{LoopStatus, Player};\nuse serde::{de, Deserialize};\n\nuse crate::{fum::Fum, regexes:"
  },
  {
    "path": "src/cli.rs",
    "chars": 1676,
    "preview": "use clap::{Parser, Subcommand};\nuse expanduser::expanduser;\n\nuse crate::{config::{Align, Config}, fum::FumResult};\n\n#[de"
  },
  {
    "path": "src/config/config.rs",
    "chars": 4062,
    "preview": "use std::{collections::HashMap, fs, path::PathBuf};\nuse expanduser::expanduser;\nuse ratatui::style::Color;\nuse serde::De"
  },
  {
    "path": "src/config/defaults.rs",
    "chars": 6328,
    "preview": "use std::collections::HashMap;\n\nuse ratatui::style::Color;\n\nuse crate::{action::Action, utils::widget::generate_id, widg"
  },
  {
    "path": "src/config/keybind.rs",
    "chars": 4932,
    "preview": "use crossterm::event::{KeyCode, KeyModifiers};\nuse serde::{de, Deserialize};\n\n#[derive(Debug, Clone, PartialEq, Eq, Hash"
  },
  {
    "path": "src/config/mod.rs",
    "chars": 79,
    "preview": "mod config;\nmod defaults;\nmod keybind;\n\npub use config::*;\npub use keybind::*;\n"
  },
  {
    "path": "src/fum.rs",
    "chars": 9646,
    "preview": "use core::error;\nuse std::{io::{stdout, Stdout}, process::{Command, Stdio}, time::Duration};\n\nuse crossterm::{event::{se"
  },
  {
    "path": "src/main.rs",
    "chars": 1058,
    "preview": "mod cli;\nmod fum;\nmod state;\nmod meta;\nmod ui;\nmod utils;\nmod text;\nmod widget;\nmod config;\nmod action;\nmod regexes;\n\nus"
  },
  {
    "path": "src/meta.rs",
    "chars": 11074,
    "preview": "use std::{fs, io::{self, Cursor}, str::FromStr, time::Duration};\n\nuse base64::{prelude::BASE64_STANDARD, Engine};\nuse im"
  },
  {
    "path": "src/regexes.rs",
    "chars": 1108,
    "preview": "use lazy_static::lazy_static;\nuse regex::Regex;\n\nlazy_static! {\n    pub static ref JSONC_COMMENT_RE: Regex = Regex::new("
  },
  {
    "path": "src/state.rs",
    "chars": 896,
    "preview": "use std::collections::HashMap;\n\nuse ratatui::{layout::Rect, style::Color};\n\nuse crate::{action::Action, meta::Meta, widg"
  },
  {
    "path": "src/text.rs",
    "chars": 3937,
    "preview": "use regex::Captures;\n\nuse crate::{meta::Meta, regexes::{GET_META_RE, LOWER_RE, UPPER_RE, VAR_RE}, state::FumState, utils"
  },
  {
    "path": "src/ui.rs",
    "chars": 3557,
    "preview": "use std::collections::HashMap;\n\nuse ratatui::{layout::{Constraint, Layout, Margin, Position, Rect}, style::Stylize, widg"
  },
  {
    "path": "src/utils/align.rs",
    "chars": 3613,
    "preview": "use ratatui::{layout::{Constraint, Flex, Layout, Rect}, Frame};\n\nuse crate::config::Align;\n\npub fn center(frame: &mut Fr"
  },
  {
    "path": "src/utils/mod.rs",
    "chars": 49,
    "preview": "pub mod terminal;\npub mod align;\npub mod widget;\n"
  },
  {
    "path": "src/utils/terminal.rs",
    "chars": 219,
    "preview": "use std::io::stdout;\n\nuse crossterm::{event::DisableMouseCapture, execute};\n\npub fn restore() {\n    ratatui::restore();\n"
  },
  {
    "path": "src/utils/widget.rs",
    "chars": 2621,
    "preview": "use std::time::Duration;\nuse uuid::Uuid;\n\n#[macro_export]\nmacro_rules! get_size {\n    ($orientation:expr, $size:expr, $a"
  },
  {
    "path": "src/widget/button.rs",
    "chars": 1059,
    "preview": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get"
  },
  {
    "path": "src/widget/container.rs",
    "chars": 1635,
    "preview": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, style::Stylize, widgets::{Block, StatefulWidge"
  },
  {
    "path": "src/widget/cover_art.rs",
    "chars": 1486,
    "preview": "use ratatui::{buffer::Buffer, layout::{Constraint, Flex, Layout, Rect}, style::Stylize, text::Text, widgets::{Block, Sta"
  },
  {
    "path": "src/widget/empty.rs",
    "chars": 478,
    "preview": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Widget}};\n\nuse crate::{get_color, state::Fu"
  },
  {
    "path": "src/widget/label.rs",
    "chars": 1455,
    "preview": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get"
  },
  {
    "path": "src/widget/mod.rs",
    "chars": 122,
    "preview": "mod widget;\nmod container;\nmod cover_art;\nmod label;\nmod button;\nmod progress;\nmod volume;\nmod empty;\n\npub use widget::*"
  },
  {
    "path": "src/widget/progress.rs",
    "chars": 3370,
    "preview": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wr"
  },
  {
    "path": "src/widget/volume.rs",
    "chars": 3140,
    "preview": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wr"
  },
  {
    "path": "src/widget/widget.rs",
    "chars": 8142,
    "preview": "use ratatui::{buffer::Buffer, layout::{Constraint, Rect}, style::Color, widgets::StatefulWidget};\nuse serde::Deserialize"
  }
]

About this extraction

This page contains the full source code of the qxb3/fum GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 64 files (142.8 KB), approximately 37.0k tokens, and a symbol index with 117 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!