[
  {
    "path": ".fpm",
    "content": "-s dir\n--name fum\n--description \"A tui-based mpris music client.\"\n--license MIT\n--maintainer \"qxb3 <qxbthree@gmail.com>\"\n--url \"https://github.com/qxb3/fum\"\n--architecture x86_64\n--prefix /usr/bin\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: Release\n\non:\n  push:\n    tags:\n      - 'v*'\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v2\n\n      - name: Install system dependencies\n        run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev pkg-config wget libssl-dev squashfs-tools -y build-essential\n\n      - name: Set up Rust\n        uses: actions-rs/toolchain@v1\n        with:\n          toolchain: stable\n          override: true\n\n      - name: Compile fum\n        run: cargo build --release --locked\n\n      - name: Add postfix to binary\n        run: |\n          cp target/release/fum target/release/fum-x86-64_${{ github.ref_name }}\n\n      - name: Build .deb package\n        uses: bpicode/github-action-fpm@master\n        with:\n          fpm_args: fum\n          fpm_opts: -t deb --version ${{ github.ref_name }} -p fum-x86-64_${{ github.ref_name }}.deb -C target/release\n\n      - name: Build .rpm package\n        uses: bpicode/github-action-fpm@master\n        with:\n          fpm_args: fum\n          fpm_opts: -t rpm --version ${{ github.ref_name }} -p fum-x86-64_${{ github.ref_name }}.rpm -C target/release\n\n      - name: Create GitHub Release\n        uses: softprops/action-gh-release@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          generate_release_notes: true\n          make_latest: true\n          files: |\n            target/release/fum-x86-64_${{ github.ref_name }}\n            fum-x86-64_${{ github.ref_name }}.deb\n            fum-x86-64_${{ github.ref_name }}.rpm\n        - name: Ensure binary is executable\n          run: chmod +x target/release/fum-x86-64_${{ github.ref_name }}\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contribution Guide\n\nThank you for your interest in contributing! Please follow these guidelines to keep the project clean and manageable.\n\n## Branching\n- Always work on the `dev` branch.\n- Never push directly to `main`; changes should go through `dev` first.\n- Use feature branches (e.g., `fix-command` or `add-something`) for new additions.\n\n## Commit Rules\n- Keep commits focused on a single change.\n- Use meaningful commit messages.\n- Split up commits for different purposes (e.g., bug fixes, refactoring, new features should be separate commits).\n\n## Pull Requests\n- Always create PRs against the `dev` branch.\n- Ensure your changes do not break existing functionality.\n- Keep PRs focused on a single purpose.\n\n---\n\nHappy fumming!!\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"fum-player\"\ndescription = \"A tui-based mpris music client.\"\nversion = \"1.3.1\"\nrepository = \"https://github.com/qxb3/fum\"\nhomepage = \"https://github.com/qxb3/fum\"\nlicense = \"MIT\"\nedition = \"2021\"\n\n[[bin]]\nname = \"fum\"\npath = \"./src/main.rs\"\n\n[dependencies]\nbase64 = \"0.22.1\"\nclap = { version = \"4.5.23\", features = [\"derive\"] }\ncrossterm = \"0.28.1\"\nexpanduser = \"1.2.2\"\nimage = \"0.25.5\"\nlazy_static = \"1.5.0\"\nmpris = \"2.0.1\"\nratatui = { version = \"0.29.0\", features = [\"all-widgets\", \"serde\"] }\nratatui-image = { version = \"4.1.0\", features = [\"crossterm\"] }\nregex = \"1.11.1\"\nreqwest = { version = \"0.12.9\", features = [\"blocking\"] }\nserde = { version = \"1.0.217\", features = [\"derive\"] }\nserde_json = \"1.0.134\"\nunicode-width = \"0.2.0\"\nuuid = { version = \"1.12.0\", features = [\"v4\", \"fast-rng\"] }\n\n[profile.release]\nopt-level = 3\nlto = \"fat\"\ncodegen-units = 1\npanic = \"abort\"\nstrip = true\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2025 qxb3\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<h3 align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/qxb3/fum/refs/heads/main/repo/logo.png\" width=\"200\"/>\n</h3>\n\n<h2 align=\"center\">\n  fum: A fully customizable tui-based mpris music client.\n</h2>\n\n<p align=\"center\">\n  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.\n</p>\n\n# ❗❗ IMPORTANT ❗❗\n> ⚠️ **Currently in a full codebase rewrite**\n> See [#98](https://github.com/qxb3/fum/issues/98) for the motivations on why. Though you can still use fum as it is.\n\n\n<p align=\"center\">\n  <a href=\"https://discord.gg/UfXMeyZ6Zt\">\n    <img src=\"https://img.shields.io/discord/1331325131649454184?style=for-the-badge&logo=discord&logoColor=%23ffffff&label=discord&labelColor=1C1B22&color=DEFEDF\" />\n  </a>\n\n  <a href=\"https://github.com/qxb3/fum/blob/main/LICENSE\">\n    <img src=\"https://img.shields.io/badge/MIT-DEFEDF?style=for-the-badge&logo=Pinboard&label=License&labelColor=1C1B22\" />\n  </a>\n\n  <a href=\"https://github.com/qxb3/fum/stargazers\">\n    <img src=\"https://img.shields.io/github/stars/qxb3/fum?style=for-the-badge&logo=Apache%20Spark&logoColor=ffffff&labelColor=1C1B22&color=DEFEDF\" />\n  </a>\n</p>\n\n## Demo\n\n<img\n  width=\"800px\"\n  src=\"https://github.com/user-attachments/assets/930283d8-6299-4ef9-865b-26960dcee866\"\n/>\n\n## Installation\n\n[![Packaging status](https://repology.org/badge/vertical-allrepos/fum.svg)](https://repology.org/project/fum/versions)\n\n## Build from source\n\n```bash\ngit clone https://github.com/qxb3/fum.git\ncd fum\ncargo build --release\n# Either copy/move `target/release/yum` to /usr/bin\n# Or add the release path to your system's path\n# Moving fum binary to /usr/bin\nmv target/release/fum /usr/bin\n```\n\n### Configuring\n\nSee [Wiki](https://github.com/qxb3/fum/wiki/Configuring)\n\n### Need help?\n\nJoin [Discord Server!](https://discord.gg/UfXMeyZ6Zt).\n\n## Showcase on a rice\n\n<img src=\"https://github.com/qxb3/fum/blob/main/repo/showcase.png\" />\n\n## Contributing\n\n[CONTRIBUTING](https://github.com/qxb3/fum/blob/main/CONTRIBUTING.md)\n\n## LICENSE\n\n[MIT](https://github.com/qxb3/fum/blob/main/LICENSE)\n"
  },
  {
    "path": "doc-site/.gitignore",
    "content": "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.*\n!.env.example\n!.env.test\n\n# Vite\nvite.config.js.timestamp-*\nvite.config.ts.timestamp-*\n"
  },
  {
    "path": "doc-site/.npmrc",
    "content": "engine-strict=true\n"
  },
  {
    "path": "doc-site/DOC_VERSION",
    "content": "1.3.0\n"
  },
  {
    "path": "doc-site/README.md",
    "content": "# docs-site\n\nDocumentation site for fum.\n"
  },
  {
    "path": "doc-site/docs-content/01_getting_started/doc.md",
    "content": "---\ntitle: Getting Started\nnext: /docs/configuring:Configuring\n---\n\n# Getting Started\n\n---\n\n## Installation\n\nTo get started with fum. You will need to install fum to your system.\n\n<br>\n\n[![Packaging status](https://repology.org/badge/vertical-allrepos/fum.svg)](https://repology.org/project/fum/versions)\n\n<br>\n\n### Arch\n\n```bash\nyay -S fum-bin\n# paru -S fum-bin\n```\n\n### Nix (Home Manager)\n\n```nix\n{ pkgs, ... }: {\n  home.packages = with pkgs; [ fum ];\n}\n```\n\n```bash\nhome-manager switch\n```\n\n### Debian based systems\n\nDownload the latest deb from [releases](https://github.com/qxb3/fum/releases) first and:\n\n```bash\nsudo dpkg -i fum-x86-64_v{DOC_VERSION}.deb\n```\n\n### RPM based systems\n\nDownload the latest rpm from [releases](https://github.com/qxb3/fum/releases) first and:\n\n```bash\nsudo dnf install fum-x86-64_v{DOC_VERSION}.rpm\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/qxb3/fum.git\ncd fum\ncargo build --release\n# Either copy/move `target/release/yum` to /usr/bin\n# Or add the release path to your system's path\n# Moving fum binary to /usr/bin\nmv target/release/fum /usr/bin\n```\n\n## Usage\n\nNOTE: if it says \"No Music\" and you don't use spotify (default), See Below.\n\n```bash\nfum\n```\n\n### Detecting other music players.\n\nIf you use different music player pass it on fum to detect:\n\n```bash\nfum --players player_identity_name,other.player.bus_name # Seperated by comma and can use identity name or bus name.\n```\n\n### List Players\n\nTo check what the music player's *identity* or *bus* name is, use:\n\n```bash\nfum list-players\nActive Players:\n* spotify ~> org.mpris.MediaPlayer2.spotify\n```\n\n### Compatibility\n\nSome 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.\n"
  },
  {
    "path": "doc-site/docs-content/02_configuring/doc.md",
    "content": "---\ntitle: Configuring\nprev: /docs/getting_started:Getting Started\nnext: /docs/faq:FAQ\n---\n\n# Configuring\n\nThis entire section will be covering how to configure fum.\n\n---\n\n## Overview\n\nFum's configuration is located on `$HOME/.config/fum/config.jsonc`.\nThis config file will be containing all of fum's functionality, look, and behavior.\n\nAlso take a note that whatever you put in here will be overwritten by the cli.\nSo for example you have `spotify` in the `players` in the config but have `mpd`\nin 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.\n\n---\n\n### Example Config\n\n```jsonc\n{\n  \"players\": [\"spotify\", \"lollypop\", \"org.mpris.MediaPlayer2.mpv\"], // List of music players to be detected.\n  \"use_active_player\": false,                                       // Whether to detect other active mpris player.\n  \"fps\": 10,                                                        // Fps of fum.\n  \"keybinds\": {                                                     // Keybinds.\n    \"esc;q\": \"quit()\",                                                // escape or q key to quit.\n    \"h\": \"prev()\",                                                    // h key to previous music.\n    \"l\": \"next()\",                                                    // l key to next music.\n    \" \": \"play_pause()\"                                               // space key to play or pause music.\n  },\n  \"align\": \"center\",                                                // Where in the terminal should fum be placed.\n  \"direction\": \"vertical\",                                          // The parent direction of layout.\n  \"flex\": \"start\",                                                  // The parent flex of layout.\n  \"width\": 20,                                                      // The width allocated.\n  \"height\": 18,                                                     // The height allocated.\n  \"border\": false,                                                  // Whether to render a border around.\n  \"padding\": [0, 0],                                                // Whether to add horizontal,vertical padding.\n  \"bg\": \"reset\",                                                    // The parent background color.\n  \"fg\": \"reset\",                                                    // The parent foreground color.\n  \"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.\n  \"layout\": []                                                      // Where we define our ui layout.\n}\n```\n\n---\n\n### Config Properties\n\n#### * `players` (Optional)\n\n\\- List of player names that will be detected by fum. This can be the name or the bus_name of the player.\nNote that identity is case insensitive and bus_name are not.\n<br>\n\n- Type: `string[]`\n- Default: `[\"spotify\"]`\n\n#### * `use_active_player` (Optional)\n\n\\- Whether to use the most likely active player when there it can't find players on the players array.\n<br>\n\n- Type: `boolean`\n- Default: `false`\n\n#### * `keybinds` (Optional)\n\n\\- Keybinds to do [#Actions](#actions). Keybinds are defined into `key` and `action`.\n\nThe dropdown below is the list of available keys you can use\n\n<details>\n  <summary>Keys List</summary>\n\n  - `backspace`\n  - `enter`\n  - `left` - `left arrow key`\n  - `right` - `right arrow key`\n  - `down` - `down arrow key`\n  - `up` - `up arrow key`\n  - `end`\n  - `page_up`\n  - `page_down`\n  - `tab`\n  - `back_tab` - `shift + tab key`\n  - `delete`\n  - `insert`\n  - `caps` - `capslock key`\n  - `esc`\n  - `f1` to `f12`\n  - `a-z, A-Z` - `alphabetical characters`\n  - `{}[]|;:'\"&#96;,.<>/?` - `keyboard symbols`\n</details>\n\n<br>\n\n- Type: `Object`\n- Default:\n\n```jsonc\n{\n  \"esc;q\": \"quit()\",\n  \"h\": \"prev()\",\n  \"l\": \"next()\",\n  \" \": \"play_pause()\"\n}\n```\n\n#### * `align` (Optional)\n\n\\- Where in the terminal fum will be positioned in.\n<br>\n\n- Type: `string`\n- Values:\n  - `left`\n  - `top`\n  - `right`\n  - `bottom`\n  - `center`\n  - `top-left`\n  - `top-right`\n  - `bottom-left`\n  - `bottom-right`\n- Default: `center`\n\n#### * `direction` (Optional)\n\n\\- See [#Direction](#direction).\n<br>\n\n- Type: `string`\n- Default: `horizontal`\n\n#### * `flex` (Optional)\n\n\\- See [#ContainerFlex](#containerflex).\n<br>\n\n- Type: `string`\n- Default: `start`\n\n#### * `width` (Optional)\n\n\\- The allocated width.\n<br>\n\n- Type: `number`\n- Default: `19`\n\n#### * `height` (Optional)\n\n\\- The allocated height.\n<br>\n\n- Type: `number`\n- Default: `15`\n\n#### * `border` (Optional)\n\n\\- Whether to render a border around.\n<br>\n\n- Type: `boolean`\n- Default: `false`\n\n#### * `padding` (Optional)\n\n\\- Whether to add horizontal,vertical padding.\n<br>\n\n- Type: `[number, number]`\n- Default: `[0, 0]`\n\n#### * `cover_art_ascii` (Optional)\n\n\\- The ascii art or text to be displayed in place of cover-art if it doesn't exists or there is no current music.\n<br>\n\n- Type: `string`\n- Default: `\"\"`\n\n#### * `layout` (Optional)\n\n\\- Layout ui to be rendered. See [#Widgets](#widgets) For all the available widgets.\n<br>\n\n- Type: `widget[]`\n- Default: [#Example Full Config](#example-full-config)\n\n---\n\n### Widgets\n\nList of available widgets.\n\n#### `Container`\n\n\\- A container containing widgets.\n<br>\n\n- Fields:\n  - `width`: `number` (optional). Specifies the width of the container. See [#Width & Height](#width--height).\n  - `height`: `number` (optional). Specifies the height of the container. See [#Width & Height](#width--height).\n  - `border`: `boolean` (Optional). Whether to draw a border around the widget. Default: `false`\n  - `padding`: `[number, number]` (Optional). Whether to add padding on the container. Default: `[0, 0]`\n  - `direction`: `string` (Optional). Specifies the layout direction of child widgets. See [#Direction](#direction).\n  - `flex`: `string` (Optional). Specifies how space is distributed among child widgets. See [#ContainerFlex](#containerflex).\n  - `bg`: `string` (Optional). The background color of this container area. See [#Bg & Fg](#bg--fg).\n  - `fg`: `string` (Optional). The foreground color of the children. See [#Bg & Fg](#bg--fg).\n  - `children`: `widget[]` (Required). The childrens of the container.\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"container\",\n  \"width\": 20,\n  \"height\": 20,\n  \"border\": false,\n  \"padding\": [0, 0],\n  \"direction\": \"vertical\",\n  \"flex\": \"start\",\n  \"bg\": \"blue\",\n  \"fg\": \"black\",\n  \"children\": [\n    {\n      \"type\": \"empty\",\n      \"size\": 1\n    }\n  ]\n}\n```\n\n#### `CoverArt`\n\n\\- Displays music cover art.\n<br>\n\n- Fields:\n  - `width`: `number` (optional). Specifies the width of the container. See [#Width & Height](#width--height).\n  - `height`: `number` (optional). Specifies the height of the container. See [#Width & Height](#width--height).\n  - `border`: `boolean` (Optional). Whether to draw a border around the widget. Default: `false`\n  - `resize`: `string` (Optional). Specifies which resize method to use.\n    - Values:\n      - `fit` - If the width or height is smaller than the area, the image will be resized maintaining proportions.\n      - `crop` - If the width or height is smaller than the area, the image will be cropped.\n      - `scale` - Scale the image.\n    - Default: `scale`\n  - `bg`: `string` (Optional). The background color of this container area. See [#Bg & Fg](#bg--fg).\n  - `fg`: `string` (Optional). The foreground color of the children. See [#Bg & Fg](#bg--fg).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"cover-art\",\n  \"width\": 20,\n  \"height\": 20,\n  \"border\": false,\n  \"resize\": \"scale\",\n  \"bg\": \"red\",\n  \"fg\": \"#000000\"\n}\n```\n\n#### `Label`\n\n\\- Displays a text label.\n<br>\n\n- Fields:\n  - `text`: `string` (Required). The text to display in the label. See [#Text](#text).\n  - `align`: `string` (Optional). Specifies the alignment of the text. See [#LabelAlignment](#labelalignment).\n  - `truncate`: `boolean` (Optional). Specifies whether to truncate the text if it exceeds the available space.\n    - Default: `true`\n  - `bold`: `boolean` (Optional). Makes the label text bold. .\n    - Default: `false`\n  - `bg`: `string` (Optional). The background color of the label. See [#Bg & Fg](#bg--fg).\n  - `fg`: `string` (Optional). The foreground color of the label. See [#Bg & Fg](#bg--fg).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"label\",\n  \"text\": \"$title\",\n  \"align\": \"center\",\n  \"truncate\": true,\n  \"bold\": false,\n  \"bg\": \"black\",\n  \"fg\": \"white\"\n}\n```\n\n#### `Button`\n\n\\- Very similar on [#Label](#label) in terms of display but this one is interactable.\n<br>\n\n- Fields:\n  - `text`: `string` (Required). The text to display in the button. See [#Text](#text).\n  - `action`: `string` (Optional). Specifies an action to perform when the button is clicked. See [#Actions](#actions).\n  - `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).\n  - `bold`: `boolean` (Optional). Makes the label text bold. .\n    - Default: `false`\n  - `bg`: `string` (Optional). The background color of the button. See [#Bg & Fg](#bg--fg).\n  - `fg`: `string` (Optional). The foreground color of the button. See [#Bg & Fg](#bg--fg).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"button\",\n  \"text\": \"$status_icon\",\n  \"action\": \"play_pause()\",\n  \"exec\": \"echo hi\",\n  \"bold\": false,\n  \"bg\": \"reset\",\n  \"fg\": \"magenta\"\n}\n```\n\n#### `Progress`\n\n\\- Displays a progress bar.\n<br>\n\n- Fields:\n  - `size`: `number` (optional). Specifies the width of the progress bar. See [#Width & Height](#width--height).\n  - `direction`: `string` (Optional). Whether to display the progress bar horizontally or vertically. See [#Direction](#direction).\n  - `progress`: string (Required).\n    - `char`: The character used to represent the progress portion of the progress bar.\n    - `bg`: The background color of the progress. See [#Bg & Fg](#bg--fg).\n    - `fg`: The foreground color of the progress. See [#Bg & Fg](#bg--fg).\n  - `empty`: string (Required).\n    - `char`: The character used to represent the empty portion of the progress bar.\n    - `bg`: The background color of the empty. See [#Bg & Fg](#bg--fg).\n    - `fg`: The foreground color of the empty. See [#Bg & Fg](#bg--fg).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"progress\",\n  \"size\": 10,\n  \"direction\": \"horizontal\"\n  \"progress\": {\n    \"char\": \"\",\n     \"fg\": \"red\",\n     \"bg\": \"blue\"\n  },\n  \"empty\": {\n    \"char\": \"-\",\n     \"fg\": \"blue\",\n     \"bg\": \"red\"\n  }\n}\n```\n\n#### `Volume`\n\n\\- Displays a volume bar.\n<br>\n\n- Fields:\n  - `size`: `number` (optional). Specifies the width of the volume bar. See [#Width & Height](#width--height).\n  - `direction`: `string` (Optional). Whether to display the volume bar horizontally or vertically. See [#Direction](#direction).\n  - `volume`: string (Required).\n    - `char`: The character used to represent the volume portion of the volume bar.\n    - `bg`: The background color of the volume. See [#Bg & Fg](#bg--fg).\n    - `fg`: The foreground color of the volume. See [#Bg & Fg](#bg--fg).\n  - `empty`: string (Required).\n    - `char`: The character used to represent the empty portion of the volume bar.\n    - `bg`: The background color of the empty. See [#Bg & Fg](#bg--fg).\n    - `fg`: The foreground color of the empty. See [#Bg & Fg](#bg--fg).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"volume\",\n  \"size\": 10,\n  \"direction\": \"vertical\",\n  \"volume\": {\n    \"char\": \"/\",\n     \"fg\": \"red\",\n     \"bg\": \"blue\"\n  },\n  \"empty\": {\n    \"char\": \" \",\n     \"fg\": \"blue\",\n     \"bg\": \"red\"\n  }\n}\n```\n\n#### `Empty`\n\n\\- Displays an empty area. Useful for spacing.\n<br>\n\n- Fields:\n  - `size`: `number` (optional). Specifies the width of the empty space. See [#Width & Height](#width--height).\n\n<br>\n\n- Example:\n\n```jsonc\n{\n  \"type\": \"empty\",\n  \"size\": 1\n}\n```\n\n---\n\n### Widget Properties\n\nList of widget properties that will be used on widget fields.\n\n#### Width & Height\n\n\\- width and height are often optional properties. When not defined, the widget will automatically fill the remaining available space.\n\n#### Direction\n\n\\- This property specifies the layout direction of the component.\n<br>\n\n- Values:\n  - `horizontal`\n  - `vertical`\n- Default: `horizontal`\n\n#### LabelAlignment\n\n\\- Specifies the alignment of text within a label.\n<br>\n\n- Values:\n  - `left`\n  - `center`\n  - `right`\nDefault: `left`\n\n#### ContainerFlex\n\n\\- Defines how space is distributed among items in a container.\n<br>\n\n- Values:\n  - `start`\n  - `center`\n  - `end`\n  - `space-around`\n  - `space-between`\n- Default: `start`\n\n#### Bg & Fg\n\nVariants:\n  - `reset` - The default color.\n  - `black`\n  - `white`\n  - `green` / `lightgreen`\n  - `yellow` / `lightyellow`\n  - `blue` / `lightblue`\n  - `red` / `lightred`\n  - `magenta` / `lightmagenta`\n  - `cyan` / `lightcyan`\n  - `gray` / `darkgray`\n  - `rgb` - An RGB color. Example: `\"fg\": {\"Rgb\": [255, 0, 255]}`\n  - `indexed` - An 8-bit 256 color. Example {\"fg\": {\"Indexed\":10}}\n- Default: `reset`\n\n#### Text\n\n- Available variables:\n  - `$title` - Title of the music.\n  - `$artists` - Artists of the music.\n  - `$album` - Album name of the music.\n  - `$status-icon` - A single ascii icon that represents the status / playback state.\n  - `$status-text` - Similar to $status-icon but in text format instead of nerdfonts icon.\n  - `$position` - The current position / progress of the music.\n  - `$position-ext` - Same as $position but prepended 0 at the start.\n  - `$length` - The total length of the music.\n  - `$length-ext` - Same as $length but prepended 0 at the start.\n  - `$remaining-length` - The remaining length of the music.\n  - `$remaining-length-ext` - Same as $remaining-length but prepended 0 at the start.\n  - `$volume` - The current player volume (0 - 100).\n<br>\n\n- Text functions:\n  - `get_meta(key: string)` - Get a specific metadata that is not available in the variables above.\n  - `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.\n\n#### Actions\n\n- Available actions:\n  - `quit()` - Quits fum.\n  - `stop()` - Stops the player.\n  - `play()` - Play the music.\n  - `pause()` - Pause the music.\n  - `prev()` - Back the music.\n  - `play_pause()` - Play / Pause the music.\n  - `next()` - Skips the music.\n  - `shuffle_off()` - Turn off the shuffle.\n  - `shuffle_toggle()` - Toggles the shuffle on / off.\n  - `shuffle_on()` - Turn on the shuffle.\n  - `loop_none()` - Set the loop to none.\n  - `loop_playlist()` - Set the loop to playlist.\n  - `loop_track()` - Set the loop to track.\n  - `loop_cycle()` - Cycle loop: none -> playlist -> track -> none.\n  - `forward(2500)` - Fast forward the music 2500 milliseconds.\n  - `backward(2500)` - Step backward the music 2500 milliseconds.\n  - `forward(-1)` - If -1 used in forward(-1) it will go to the end of the track.\n  - `backward(-1)` - If -1 used in backward(-1) it will go to the start of the track.\n  - `volume(+10)` - Increases the volume +10.\n  - `volume(-10)` - Decreases the volume -10.\n  - `volume(50)` - Sets the volume to 50 (0 - 100).\n  - `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.\n  - `set($foo, $title)` - Set the value of a variable, where $foo is the name and $title is the value to set.\n\n---\n\n### Example Full Config\n\n```jsonc\n{\n  \"players\": [\"spotify\", \"lollypop\", \"org.mpris.MediaPlayer2.mpv\"],\n  \"use_active_player\": false,\n  \"debug\": false,\n  \"width\": 20,\n  \"height\": 18,\n  \"layout\": [\n    { \"type\": \"cover-art\" },\n    {\n      \"type\": \"container\",\n      \"direction\": \"vertical\",\n      \"children\": [\n        { \"type\": \"label\", \"text\": \"$title\", \"align\": \"center\" },\n        { \"type\": \"label\", \"text\": \"$artists\", \"align\": \"center\" },\n        { \"type\": \"empty\", \"size\": 1 },\n        {\n          \"type\": \"container\",\n          \"height\": 1,\n          \"flex\": \"space-around\",\n          \"children\": [\n            { \"type\": \"button\", \"text\": \"󰒮\", \"action\": \"prev()\" },\n            { \"type\": \"button\", \"text\": \"$status-icon\", \"action\": \"play_pause()\" },\n            { \"type\": \"button\", \"text\": \"󰒭\", \"action\": \"next()\" }\n          ]\n        },\n        { \"type\": \"empty\", \"size\": 1 },\n        { \"type\": \"progress\", \"progress\": { \"char\": \"󰝤\" }, \"empty\": { \"char\": \"󰁱\" } },\n        {\n          \"type\": \"container\",\n          \"height\": 1,\n          \"flex\": \"space-between\",\n          \"children\": [\n            { \"type\": \"label\", \"text\": \"$position\", \"align\": \"left\" },\n            { \"type\": \"label\", \"text\": \"$length\", \"align\": \"right\" }\n          ]\n        }\n      ]\n    }\n  ]\n}\n```\n"
  },
  {
    "path": "doc-site/docs-content/03_faq/doc.md",
    "content": "---\ntitle: FAQ\nprev: /docs/configuring:Configuring\nnext: /docs/compability:Compability\n---\n\n# FAQ\n\n---\n\n## Why is there a delay in updating/changing the music?\n\n> 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.\n\n## Why is there a slight or huge cpu spike whenever music is updated/changed?\n\n> 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.\n"
  },
  {
    "path": "doc-site/docs-content/04_compability/doc.md",
    "content": "---\ntitle: Compability\nprev: /docs/faq:FAQ\nnext: /docs/rices:Rices\n---\n\n# Compability\n\n---\n\nSome terminals will have some issues of rendering the image as those don't support an image protocol yet.\nSee [Compability](https://github.com/benjajaja/ratatui-image?tab=readme-ov-file#compatibility-matrix) For compatible terminals.\n"
  },
  {
    "path": "doc-site/docs-content/05_rices/doc.md",
    "content": "---\ntitle: Rices\nprev: /docs/compability:Compability\n---\n\n# Rices\n\nCompilation of rices / customization of fum.\n\n---\n\n### * danielwerg - lowfi clone\n\n![preview](/rices/danielwerg_lowfi_clone.png)\n\n<details>\n<summary>Layout Config</summary>\n\nNOTE: Volume bar is never show ([#68](https://github.com/qxb3/fum/issues/68))\n\n```jsonc\n{\n  \"players\": [\"lowfi\"],\n  \"keybinds\": {\n    \"s;S\": \"next()\",\n    \"n;N\": \"next()\",\n    \"p;P\": \"play_pause()\",\n    \"-;_;down\": \"volume(-5)\",\n    \"left\": \"volume(-1)\",\n    \"+;=;up\": \"volume(+5)\",\n    \"right\": \"volume(+1)\",\n    \"q;Q;ctrl+c\": \"quit()\"\n  },\n  \"width\": 31,\n  \"height\": 5,\n  \"border\": true,\n  \"padding\": [2, 1],\n  \"layout\": [\n    {\n      \"type\": \"container\",\n      \"direction\": \"vertical\",\n      \"children\": [\n        {\n          \"type\": \"container\",\n          \"children\": [\n            {\n              \"type\": \"container\",\n              \"width\": 7,\n              \"children\": [\n                {\n                  \"type\": \"button\",\n                  \"text\": \"$status-text\",\n                  \"action\": \"play_pause()\"\n                }\n              ]\n            },\n            { \"type\": \"empty\", \"size\": 1 },\n            { \"type\": \"label\", \"text\": \"$title\", \"bold\": true }\n          ]\n        },\n        {\n          \"type\": \"container\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 1 },\n            {\n              \"type\": \"container\",\n              \"children\": [\n                { \"type\": \"button\", \"text\": \"[\" },\n                {\n                  \"type\": \"progress\",\n                  \"progress\": { \"char\": \"/\" },\n                  \"empty\": { \"char\": \" \" }\n                },\n                { \"type\": \"button\", \"text\": \"]\" }\n              ]\n            },\n            { \"type\": \"empty\", \"size\": 1 },\n            {\n              \"type\": \"container\",\n              \"width\": 11,\n              \"children\": [\n                { \"type\": \"button\", \"text\": \"$position-ext\" },\n                { \"type\": \"button\", \"text\": \"/\" },\n                {\n                  \"type\": \"button\",\n                  \"text\": \"var($length-style, $length-ext)\",\n                  \"action\": \"toggle($length-style, $length-ext, $remaining-length-ext)\"\n                }\n              ]\n            }\n          ]\n        },\n        {\n          \"type\": \"container\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 1 },\n            {\n              \"type\": \"container\",\n              \"width\": 7,\n              \"children\": [{ \"type\": \"label\", \"text\": \"volume:\" }]\n            },\n            { \"type\": \"empty\", \"size\": 1 },\n            {\n              \"type\": \"container\",\n              \"children\": [\n                { \"type\": \"button\", \"text\": \"[\" },\n                {\n                  \"type\": \"volume\",\n                  \"volume\": { \"char\": \"/\" },\n                  \"empty\": { \"char\": \" \" }\n                },\n                { \"type\": \"button\", \"text\": \"]\" }\n              ]\n            },\n            { \"type\": \"empty\", \"size\": 1 },\n            {\n              \"type\": \"container\",\n              \"width\": 4,\n              \"children\": [{ \"type\": \"label\", \"text\": \"$volume%\" }]\n            }\n          ]\n        },\n        {\n          \"type\": \"container\",\n          \"flex\": \"space-between\",\n          \"children\": [\n            // { \"type\": \"button\", \"text\": \"[s]kip\", \"action\": \"next()\" },\n            // { \"type\": \"button\", \"text\": \"[p]ause\", \"action\": \"play_pause()\" },\n            // { \"type\": \"button\", \"text\": \"[q]uit\", \"action\": \"quit()\" }\n            {\n              \"type\": \"container\",\n              \"width\": 6,\n              \"children\": [\n                {\n                  \"type\": \"button\",\n                  \"text\": \"[s]\",\n                  \"action\": \"next()\",\n                  \"bold\": true\n                },\n                { \"type\": \"button\", \"text\": \"kip\", \"action\": \"next()\" }\n              ]\n            },\n            {\n              \"type\": \"container\",\n              \"width\": 7,\n              \"children\": [\n                {\n                  \"type\": \"button\",\n                  \"text\": \"[p]\",\n                  \"action\": \"play_pause()\",\n                  \"bold\": true\n                },\n                { \"type\": \"button\", \"text\": \"ause\", \"action\": \"play_pause()\" }\n              ]\n            },\n            {\n              \"type\": \"container\",\n              \"width\": 6,\n              \"children\": [\n                {\n                  \"type\": \"button\",\n                  \"text\": \"[q]\",\n                  \"action\": \"quit()\",\n                  \"bold\": true\n                },\n                { \"type\": \"button\", \"text\": \"uit\", \"action\": \"quit()\" }\n              ]\n            }\n          ]\n        }\n      ]\n    }\n  ]\n}\n```\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_06.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\"],\n  \"debug\": false,\n  \"keybinds\": {\n    \"esc;q\": \"quit()\",\n    \"h\": \"prev()\",\n    \"l\": \"next()\",\n    \" \": \"play_pause()\",\n    \"-\": \"volume(-5)\",\n    \"+\": \"volume(+5)\",\n    \"left\": \"backward(2500)\",\n    \"right\": \"forward(2500)\"\n  },\n  \"width\": 33,\n  \"height\": 16,\n  \"direction\": \"vertical\",\n  \"layout\": [\n    {\n      \"type\": \"container\",\n      \"width\": 33,\n      \"height\": 11,\n      \"children\": [\n        { \"type\": \"label\", \"text\": \"$title\", \"direction\": \"vertical\", \"fg\": \"green\", \"bold\": true },\n        { \"type\": \"empty\", \"size\": 2 },\n        { \"type\": \"cover-art\" }\n      ]\n    },\n    {\n      \"type\": \"container\",\n      \"height\": 4,\n      \"direction\": \"vertical\",\n      \"children\": [\n        {\n          \"type\": \"container\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 3 },\n            { \"type\": \"button\", \"text\": \"P: \" },\n            { \"type\": \"button\", \"text\": \"[\" },\n            { \"type\": \"progress\", \"progress\": { \"char\": \"=\" }, \"empty\": { \"char\": \" \" } },\n            { \"type\": \"button\", \"text\": \"]\" }\n          ]\n        },\n        {\n          \"type\": \"container\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 3 },\n            { \"type\": \"button\", \"text\": \"V: \" },\n            { \"type\": \"button\", \"text\": \"[\" },\n            { \"type\": \"volume\", \"volume\": { \"char\": \"=\" }, \"empty\": { \"char\": \" \" } },\n            { \"type\": \"button\", \"text\": \"]\" }\n          ]\n        },\n        {\n          \"type\": \"container\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 3 },\n            { \"type\": \"button\", \"text\": \"[󰒮 prev]\" },\n            { \"type\": \"button\", \"text\": \"[$status-icon play/pause]\" },\n            { \"type\": \"button\", \"text\": \"[󰒭 next]\" }\n          ]\n        }\n      ]\n    }\n  ]\n}\n```\n\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_05.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\"],\n  \"width\": 40,\n  \"height\": 14,\n  \"layout\": [\n    {\n      \"type\": \"container\",\n      \"height\": 3,\n      \"direction\": \"vertical\",\n      \"children\": [\n        {\n          \"type\": \"label\",\n          \"text\": \"==[ $title ]==\",\n          \"align\": \"center\",\n          \"bold\": true,\n          \"fg\": \"yellow\"\n        },\n        {\n          \"type\": \"progress\",\n          \"progress\": { \"char\": \"=\", \"fg\": \"green\" },\n          \"empty\": { \"char\": \"-\", \"fg\": \"gray\" }\n        }\n      ]\n    },\n    { \"type\": \"empty\", \"size\": 1 },\n    {\n      \"type\": \"container\",\n      \"children\": [\n        { \"type\": \"cover-art\" },\n        { \"type\": \"button\", \"direction\": \"vertical\", \"text\": \"prev\", \"action\": \"prev()\" },\n        { \"type\": \"empty\", \"size\": 2 },\n        { \"type\": \"button\", \"direction\": \"vertical\", \"text\": \"$status-text\", \"action\": \"play_pause()\" },\n        { \"type\": \"empty\", \"size\": 2 },\n        { \"type\": \"button\", \"direction\": \"vertical\", \"text\": \"next\", \"action\": \"next()\" }\n      ]\n    }\n  ]\n}\n```\n\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_04.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\", \"mpv\"],\n  \"use_active_player\": true,\n  \"width\": 30,\n  \"height\": 5,\n  \"layout\": [\n    { \"type\": \"label\", \"text\": \"$title\", \"align\": \"center\", \"bold\": true },\n    { \"type\": \"label\", \"text\": \"$artists\", \"align\": \"center\" },\n    { \"type\": \"empty\", \"size\": 1 },\n    {\n      \"type\": \"container\",\n      \"flex\": \"space-between\",\n      \"children\": [\n        { \"type\": \"button\", \"text\": \"prev\", \"action\": \"prev()\" },\n        { \"type\": \"button\", \"text\": \"play/pause\", \"action\": \"play_pause()\" },\n        { \"type\": \"button\", \"text\": \"next\", \"action\": \"next()\" }\n      ]\n    },\n    { \"type\": \"progress\", \"progress\": { \"char\": \"■\", \"fg\": \"white\" }, \"empty\": { \"char\": \"□\", \"fg\": \"gray\" } }\n  ]\n}\n```\n\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_03.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\"],\n  \"debug\": false,\n  \"keybinds\": {\n    \"esc;q\": \"quit()\",\n    \"h\": \"prev()\",\n    \"l\": \"next()\",\n    \" \": \"play_pause()\",\n    \"-\": \"volume(-5)\",\n    \"+\": \"volume(+5)\",\n    \"left\": \"backward(2500)\",\n    \"right\": \"forward(2500)\"\n  },\n  \"width\": 21,\n  \"height\": 15,\n  \"direction\": \"vertical\",\n  \"layout\": [\n    { \"type\": \"label\", \"text\": \"> $title <\", \"align\": \"center\" },\n    { \"type\": \"label\", \"text\": \"> $artists <\", \"align\": \"center\" },\n    { \"type\": \"empty\", \"size\": 1 },\n    { \"type\": \"cover-art\" },\n    { \"type\": \"empty\", \"size\": 1 },\n    {\n      \"type\": \"container\",\n      \"height\": 1,\n      \"flex\": \"space-around\",\n      \"children\": [\n        { \"type\": \"button\", \"text\": \"󰒝\", \"action\": \"shuffle_toggle()\" },\n        { \"type\": \"button\", \"text\": \"󰒮\", \"action\": \"prev()\" },\n        { \"type\": \"button\", \"text\": \"$status-icon\", \"action\": \"play_pause()\" },\n        { \"type\": \"button\", \"text\": \"󰒭\", \"action\": \"next()\" },\n        { \"type\": \"button\", \"text\": \"󰑐\", \"action\": \"loop_track()\" }\n      ]\n    },\n    { \"type\": \"empty\", \"size\": 1 },\n    { \"type\": \"progress\", \"progress\": { \"char\": \">\" }, \"empty\": { \"char\": \"<\" } }\n  ]\n}\n```\n\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_02.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\"],\n  \"debug\": false,\n  \"keybinds\": {\n    \"esc;q\": \"quit()\",\n    \"h\": \"prev()\",\n    \"l\": \"next()\",\n    \" \": \"play_pause()\",\n    \"-\": \"volume(-5)\",\n    \"+\": \"volume(+5)\",\n    \"left\": \"backward(2500)\",\n    \"right\": \"forward(2500)\"\n  },\n  \"width\": 43,\n  \"height\": 8,\n  \"direction\": \"horizontal\",\n  \"layout\": [\n    { \"type\": \"cover-art\" },\n    { \"type\": \"empty\", \"size\": 2 },\n    {\n      \"type\": \"container\",\n      \"direction\": \"vertical\",\n      \"children\": [\n        { \"type\": \"label\", \"text\": \"󰝚 $title\" },\n        { \"type\": \"label\", \"text\": \"󰠃 $artists\" },\n        { \"type\": \"label\", \"text\": \"󰓎 get_meta(xesam:autoRating)\" },\n        { \"type\": \"label\", \"text\": \" get_meta(xesam:discNumber)\" },\n        { \"type\": \"container\", \"children\": [] },\n        {\n          \"type\": \"container\",\n          \"height\": 1,\n          \"children\": [\n            { \"type\": \"button\", \"text\": \"󰒮\", \"action\": \"prev()\" },\n            { \"type\": \"empty\", \"size\": 3 },\n            { \"type\": \"button\", \"text\": \"$status-icon\", \"action\": \"play_pause()\" },\n            { \"type\": \"empty\", \"size\": 3 },\n            { \"type\": \"button\", \"text\": \"󰒭\", \"action\": \"next()\" }\n          ]\n        },\n        { \"type\": \"progress\", \"progress\": { \"char\": \"\" }, \"empty\": { \"char\": \"-\" } },\n        {\n          \"type\": \"container\",\n          \"flex\": \"space-between\",\n          \"height\": 1,\n          \"children\": [\n            { \"type\": \"button\", \"text\": \"$position\" },\n            { \"type\": \"button\", \"text\": \"var($len-style, $length)\", \"action\": \"toggle($len-style, $length, $remaining-length)\" }\n          ]\n        }\n      ]\n    }\n  ]\n}\n```\n\n</details>\n\n### * qxb3\n\n![preview](/rices/preconfig_01.png)\n\n<details>\n<summary>Layout Config</summary>\n\n```jsonc\n{\n  \"players\": [\"spotify\"],\n  \"debug\": false,\n  \"keybinds\": {\n    \"esc;q\": \"quit()\",\n    \"h\": \"prev()\",\n    \"l\": \"next()\",\n    \" \": \"play_pause()\",\n    \"-\": \"volume(-5)\",\n    \"+\": \"volume(+5)\",\n    \"left\": \"backward(2500)\",\n    \"right\": \"forward(2500)\"\n  },\n  \"width\": 22,\n  \"height\": 10,\n  \"layout\": [\n    {\n      \"type\": \"container\",\n      \"direction\": \"horizontal\",\n      \"children\": [\n        {\n          \"type\": \"container\",\n          \"direction\": \"vertical\",\n          \"width\": 20,\n          \"children\": [\n            { \"type\": \"label\", \"text\": \"$title\" },\n            { \"type\": \"cover-art\" },\n            { \"type\": \"progress\", \"progress\": { \"char\": \"󰝤\" }, \"empty\": { \"char\": \"󰁱\" } }\n          ]\n        },\n        { \"type\": \"empty\", \"size\": 1 },\n        {\n          \"type\": \"container\",\n          \"direction\": \"vertical\",\n          \"children\": [\n            { \"type\": \"empty\", \"size\": 1 },\n            { \"type\": \"button\", \"text\": \"󰒮\", \"action\": \"prev()\" },\n            { \"type\": \"empty\", \"size\": 1 },\n            { \"type\": \"button\", \"text\": \"$status-icon\", \"action\": \"play_pause()\" },\n            { \"type\": \"empty\", \"size\": 1 },\n            { \"type\": \"button\", \"text\": \"󰒭\", \"action\": \"next()\" }\n          ]\n        }\n      ]\n    }\n  ]\n}\n```\n\n</details>\n"
  },
  {
    "path": "doc-site/package.json",
    "content": "{\n  \"name\": \"fum-doc-site\",\n  \"private\": true,\n  \"version\": \"0.0.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite dev --host\",\n    \"build\": \"vite build\",\n    \"deploy\": \"npm run build && gh-pages -d build -b docs\",\n    \"preview\": \"vite preview\",\n    \"prepare\": \"svelte-kit sync || echo ''\",\n    \"check\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json\",\n    \"check:watch\": \"svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch\"\n  },\n  \"devDependencies\": {\n    \"@sveltejs/adapter-static\": \"^3.0.8\",\n    \"@sveltejs/kit\": \"^2.16.0\",\n    \"@sveltejs/vite-plugin-svelte\": \"^5.0.0\",\n    \"@tailwindcss/vite\": \"^4.0.0\",\n    \"@types/node\": \"^22.13.5\",\n    \"gh-pages\": \"^6.3.0\",\n    \"mdsvex\": \"^0.12.3\",\n    \"rehype-autolink-headings\": \"^7.1.0\",\n    \"rehype-slug\": \"^6.0.0\",\n    \"shiki\": \"^3.0.0\",\n    \"svelte\": \"^5.0.0\",\n    \"svelte-check\": \"^4.0.0\",\n    \"tailwindcss\": \"^4.0.0\",\n    \"typescript\": \"^5.0.0\",\n    \"vite\": \"^6.0.0\"\n  }\n}\n"
  },
  {
    "path": "doc-site/src/app.css",
    "content": "@import 'tailwindcss';\n\n@theme {\n  --font-embed-code:  \"Jersey 15\", serif;\n  --color-background: #1c1b22;\n  --color-fg:         #defedf;\n  --color-primary:    #40e78a;\n  --color-secondary:  #3a86ff;\n  --color-tertiary:   #9b5de5;\n  --color-subtle:     #727169;\n}\n\nhtml {\n  @apply scroll-smooth h-full;\n}\n\nbody {\n  @apply bg-background text-fg font-embed-code;\n}\n\n.doc p,\n.doc li{ @apply text-2xl; }\n\n.doc h1 { @apply relative text-6xl font-bold my-3; }\n.doc h2 { @apply relative text-5xl font-bold my-3; }\n.doc h3 { @apply relative text-4xl font-bold my-3; }\n.doc h4 { @apply relative text-3xl font-bold my-3; }\n.doc h5 { @apply relative text-2xl font-bold my-3; }\n.doc h6 { @apply relative text-xl font-bold my-3; }\n\n.doc h1,\n.doc h2,\n.doc h3,\n.doc h4,\n.doc h5,\n.doc h6 { @apply text-primary; }\n.doc a:not(h1 a, h2 a, h3 a, h4 a, h5 a, h6 a) { @apply text-secondary; }\n\n.doc ol { @apply list-decimal pl-4; }\n.doc ul { @apply list-disc pl-4; }\n\n.doc table { @apply w-full; }\n.doc thead { @apply bg-primary text-background; }\n.doc th, td { @apply px-4 py-2 border border-fg text-left; }\n\n.doc blockquote { @apply border-l-4 border-gray-400 dark:border-gray-600 pl-4 italic text-gray-700 dark:text-gray-300; }\n\n.doc pre { @apply mb-4 p-2; }\n.doc code:not(pre code) { @apply bg-[#16161d] font-embed-code px-2; }\n\n.doc summary { @apply text-2xl cursor-pointer hover:text-primary transition-colors duration-300 w-fit; }\n\n.doc hr { @apply my-8; }\n\n.doc pre:has(code) {\n  position: relative;\n  display: flex;\n\n  code {\n    overflow-x: scroll;\n  }\n\n  button.copy {\n    position: absolute;\n    right: 0;\n    top: 0;\n    height: 24px;\n    width: 24px;\n    margin: .5em;\n  \n    & span {\n      display: inline-block;\n      width: 100%;\n      height: 100%;\n      mask-size: cover;\n      cursor: pointer;\n    }\n    & .ready {\n      mask-image: url(/icons/copy.svg);\n      background-color: var(--color-subtle);\n    }\n    & .success {\n      display: none;\n      mask-image: url(/icons/check.svg);\n      background-color: var(--color-primary);\n    }\n  \n    &.copied {\n      & .success {\n        display: block;\n      }\n  \n      & .ready {\n        display: none;\n      }\n    }\n  }\n}"
  },
  {
    "path": "doc-site/src/app.d.ts",
    "content": "// See https://svelte.dev/docs/kit/types#app.d.ts\n// for information about these interfaces\ndeclare global {\n\tnamespace App {\n\t\t// interface Error {}\n\t\t// interface Locals {}\n\t\t// interface PageData {}\n\t\t// interface PageState {}\n\t\t// interface Platform {}\n\t}\n}\n\nexport {};\n"
  },
  {
    "path": "doc-site/src/app.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%sveltekit.assets%/favicon.png\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\n    <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\" />\n\n    <link href=\"https://fonts.googleapis.com/css2?family=Jersey+15&display=swap\" rel=\"stylesheet\">\n\n    %sveltekit.head%\n  </head>\n\n  <body data-sveltekit-preload-data=\"hover\">\n    %sveltekit.body%\n  </body>\n</html>\n"
  },
  {
    "path": "doc-site/src/global.d.ts",
    "content": "/// <reference types=\"@sveltejs/kit\" />\n\ndeclare module '*.md' {\n  import type { SvelteComponent } from 'svelte'\n\n  export default class Comp extends SvelteComponent{}\n\n  export const metadata: Record<string, unknown>\n}\n"
  },
  {
    "path": "doc-site/src/lib/Nav.svelte",
    "content": "<script lang=\"ts\">\n  import { sideBarStore } from '$lib/stores'\n\n  function toggleSideBar(e: MouseEvent) {\n    e.stopPropagation()\n\n    sideBarStore.update(state => !state)\n  }\n</script>\n\n<nav class=\"px-4 py-2 bg-background text-fg border-b border-fg\">\n  <div class=\"grid grid-cols-3 text-2xl\">\n    <div class=\"flex items-center justify-start space-x-4\">\n      <button\n        on:click={toggleSideBar}\n        class=\"cursor-pointer transition-colors duration-300 hover:text-primary\"\n        aria-label=\"sidebar button\">\n        <i class=\"fa-solid fa-bars\"></i>\n      </button>\n    </div>\n\n    <a\n      class=\"text-center hover:text-primary transition-colors duration-300\"\n      href=\"/\">\n      <h1 class=\"font-bold\">fum documentation</h1>\n    </a>\n\n    <div class=\"list-none flex items-center justify-end space-x-4\">\n      <a\n        class=\"text-background text-lg px-2 bg-fg hover:bg-primary transition-colors duration-300\"\n        href=\"https://github.com/qxb3/fum/releases/tag/v{DOC_VERSION}\"\n        target=\"_blank\"\n        aria-label=\"doc version\">\n        v{DOC_VERSION}\n      </a>\n\n      <a\n        class=\"hover:text-primary transition-colors duration-300\"\n        href=\"https://discord.gg/UfXMeyZ6Zt\"\n        target=\"_blank\"\n        aria-label=\"discord invite\">\n        <i class=\"fa-brands fa-discord\"></i>\n      </a>\n\n      <a\n        class=\"hover:text-primary transition-colors duration-300\"\n        href=\"https://github.com/qxb3/fum\"\n        target=\"_blank\"\n        aria-label=\"github link\">\n        <i class=\"fa-brands fa-github\"></i>\n      </a>\n    </div>\n  </div>\n</nav>\n"
  },
  {
    "path": "doc-site/src/lib/SideBar.svelte",
    "content": "<script lang=\"ts\">\n  import { onMount } from 'svelte'\n  import { sideBarStore } from '$lib/stores'\n\n  let sideBar: HTMLDivElement;\n\n  function closeSideBar() {\n    sideBarStore.set(false)\n  }\n\n  onMount(() => {\n    window.addEventListener('click', (e) => {\n      if ($sideBarStore && (sideBar && !sideBar.contains(e.target as Node))) {\n        sideBarStore.set(false)\n      }\n    })\n  })\n</script>\n\n<div\n  bind:this={sideBar}\n  class=\"fixed top-0 w-96 h-screen border-r border-r-fg z-50 bg-background transition-all duration-300\"\n  class:left-0={$sideBarStore}\n  class:-left-96={!$sideBarStore}>\n  <div class=\"relative\">\n    <button\n      class=\"absolute top-0 right-0 m-4 cursor-pointer text-md\"\n      aria-label=\"close sidebar\"\n      on:click={closeSideBar}>\n      <i class=\"fa-solid fa-xmark\"></i>\n    </button>\n  </div>\n\n  <ul class=\"h-full list-none mt-4 text-xl space-y-2 px-4\">\n    {#each DOCS as doc}\n      <li>\n        <a\n          class=\"hover:text-primary\"\n          href={doc.url}\n          on:click={closeSideBar}>\n          {doc.title}\n        </a>\n      </li>\n    {/each}\n  </ul>\n</div>\n"
  },
  {
    "path": "doc-site/src/lib/index.ts",
    "content": "export { default as Nav } from './Nav.svelte'\nexport { default as SideBar } from './SideBar.svelte'\n"
  },
  {
    "path": "doc-site/src/lib/stores.ts",
    "content": "import { writable } from 'svelte/store'\n\nexport const sideBarStore = writable(false)\n"
  },
  {
    "path": "doc-site/src/routes/+layout.js",
    "content": "export const prerender = true\n"
  },
  {
    "path": "doc-site/src/routes/+layout.svelte",
    "content": "<script lang=\"ts\">\n  import '../app.css'\n\n  import Nav from '$lib/Nav.svelte'\n  import SideBar from '$lib/SideBar.svelte'\n\n  const { children } = $props()\n</script>\n\n\n<SideBar />\n\n<div class=\"h-full\">\n  <Nav />\n\n  {@render children()}\n</div>\n"
  },
  {
    "path": "doc-site/src/routes/+page.svelte",
    "content": "<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 justify-center\">\n      <img\n        width=\"300px\"\n        src=\"/logo.png\"\n        alt=\"Logo\"\n      />\n    </div>\n\n    <h1 class=\"text-5xl font-bold\">\n      fum - A fully ricable tui-based mpris music client.\n    </h1>\n\n    <a\n      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\"\n      href=\"/docs/getting_started\">\n      <span>Get Started</span>\n      <span>\n        <i class=\"fa-solid fa-arrow-right\"></i>\n      </span>\n    </a>\n  </div>\n</div>\n"
  },
  {
    "path": "doc-site/src/routes/docs/[title]/+page.svelte",
    "content": "<script lang=\"ts\">\n  import type { PageProps } from './$types'\n\n  const { data }: PageProps = $props()\n</script>\n\n<svelte:head>\n  <title>{data.doc.title}</title>\n</svelte:head>\n\n<main class=\"doc p-8\">\n  <div class=\"overflow-x-hidden\">\n    {@html data.doc.html}\n  </div>\n\n  <hr>\n\n  <div class=\"flex justify-between items-center\">\n    {#if data.doc.prev}\n      <a\n        class=\"self-start font-bold text-fg! text-4xl cursor-pointer hover:text-background duration-300 transition-colors\"\n        href={data.doc.prev.url}>\n        <span>\n          <i class=\"fa-solid fa-chevron-left\"></i>\n        </span>\n        <span>{data.doc.prev.title}</span>\n      </a>\n    {:else}\n      <div></div>\n    {/if}\n\n    {#if data.doc.next}\n      <a\n        class=\"font-bold text-fg! text-4xl cursor-pointer hover:text-background duration-300 transition-colors\"\n        href={data.doc.next.url}>\n        <span>{data.doc.next.title}</span>\n        <span>\n          <i class=\"fa-solid fa-chevron-right\"></i>\n        </span>\n      </a>\n    {/if}\n  </div>\n</main>\n"
  },
  {
    "path": "doc-site/src/routes/docs/[title]/+page.ts",
    "content": "import type { PageLoad, EntryGenerator } from './$types'\n\nimport { error } from '@sveltejs/kit'\n\nexport const load: PageLoad = async ({ params }) => {\n  const { title } = params\n\n  const doc = DOCS\n    .find(d =>\n      d.title.toLowerCase().replaceAll(' ', '_') === title\n    )\n\n  if (!doc)\n    throw error(404, 'Documentation Not Found.')\n\n  return {\n    doc\n  }\n}\n\nexport const entries: EntryGenerator = () => {\n  return DOCS.map(d => ({ title: d.title.toLowerCase().replaceAll(' ', '_') }))\n}\n\nexport const prerender = true\n"
  },
  {
    "path": "doc-site/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ndeclare const DOC_VERSION; string\ndeclare const DOCS: {\n  url: string\n  path: string\n  raw: string\n  title: string\n  html: string\n  prev?: {\n    url: string\n    title: string\n  }\n  next?: {\n    url: string\n    title: string\n  }\n}[]\n"
  },
  {
    "path": "doc-site/svelte.config.js",
    "content": "import { mdsvex } from 'mdsvex'\nimport { vitePreprocess } from '@sveltejs/vite-plugin-svelte'\n\nimport staticAdapter from '@sveltejs/adapter-static'\n\nimport rehypeSlug from 'rehype-slug'\nimport rehypeAutolinkHeadings from 'rehype-autolink-headings'\n\n/** @type {import('@sveltejs/kit').Config} */\nconst config = {\n  preprocess: [\n    vitePreprocess(),\n    mdsvex({\n      extensions: ['.md'],\n        rehypePlugins: [\n          rehypeSlug,\n          [rehypeAutolinkHeadings, {\n            behavior: 'wrap',\n          }]\n        ]\n    })\n  ],\n\n  kit: {\n    adapter: staticAdapter(),\n    prerender: {\n      entries: ['*']\n    }\n  },\n\n  extensions: ['.svelte', '.md']\n}\n\nexport default config\n"
  },
  {
    "path": "doc-site/tailwind.config.js",
    "content": "export default {\n  content: ['./src/**/*.{svelte,ts,js}'],\n  theme: {\n    extend: {\n      colors: {\n        background: 'var(--color-background)',\n        primary: 'var(--color-primary)',\n        secondary: 'var(--color-secondary)',\n        tertiary: 'var(--color-tertiary)',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "doc-site/tsconfig.json",
    "content": "{\n  \"extends\": \"./.svelte-kit/tsconfig.json\",\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"esModuleInterop\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"skipLibCheck\": true,\n    \"sourceMap\": true,\n    \"strict\": true,\n    \"moduleResolution\": \"bundler\",\n    \"types\": [\n      \"vite/client\",\n      \"@sveltejs/kit\"\n    ]\n  }\n}\n"
  },
  {
    "path": "doc-site/vite.config.ts",
    "content": "import tailwindcss from '@tailwindcss/vite'\n\nimport { sveltekit } from '@sveltejs/kit/vite'\nimport { defineConfig } from 'vite'\n\nimport fs from 'node:fs'\nimport path from 'node:path'\n\nimport { compile, escapeSvelte } from 'mdsvex'\nimport { codeToHtml, type ShikiTransformer } from 'shiki'\n\nimport rehypeSlug from 'rehype-slug'\nimport rehypeAutolinkHeadings from 'rehype-autolink-headings'\n\nconst DOC_VERSION = fs.readFileSync('DOC_VERSION', 'utf-8').trim()\nconst DOCS = await getDocs('docs-content')\n\nexport default defineConfig({\n  plugins: [\n    sveltekit(),\n    tailwindcss()\n  ],\n  define: {\n    DOC_VERSION: JSON.stringify(DOC_VERSION),\n    DOCS: JSON.stringify(DOCS)\n  }\n})\n\ninterface addCodeblockCopyButtonOptions {\n  /** `3000` by default */\n  ms?: number\n}\nfunction addCodeblockCopyButton ({\n  ms = 3000\n}: addCodeblockCopyButtonOptions = {}) {\n  return {\n    name: 'shiki-transformer-codeblock-copy-button',\n    pre(node) {\n      node.children.push({\n        type: 'element',\n        tagName: 'button',\n        properties: {\n          className: [ 'copy' ],\n          \"data-code\": this.source,\n          onClick: `\n            navigator.clipboard.writeText(this.dataset.code);\n            this.setAttribute('disabled', true);\n            this.classList.add('copied');\n            setTimeout(() => {\n              this.classList.remove('copied');\n              this.removeAttribute('disabled');\n            }, ${ms})`\n        },\n        children: [\n          {\n            type: 'element',\n            tagName: 'span',\n            properties: { className: [ 'ready' ] },\n            children: []\n          },\n          {\n            type: 'element',\n            tagName: 'span',\n            properties: { className: [ 'success' ] },\n            children: []\n          }\n        ]\n      });\n    }\n  } satisfies ShikiTransformer;\n}\n\nasync function getDocs(docsPath: string) {\n  const docs = await Promise.all(fs\n    .readdirSync(docsPath)\n    .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))\n    .map(async docPath => {\n      const mdPath = path.join(__dirname, docsPath, docPath, 'doc.md')\n      const content = fs.readFileSync(mdPath, 'utf-8')\n      const compiledContent = await compile(content, {\n        rehypePlugins: [\n          rehypeSlug,\n          [rehypeAutolinkHeadings, {\n            behavior: 'wrap',\n          }],\n\n          // Custom extension to replace {DOC_VERSION} to current version in the readme.\n          () => (tree: any) => {\n            function replaceTextNodes(node: any) {\n              if (typeof node.value === 'string') {\n                node.value = node.value.replace(/({|&#123;)DOC_VERSION(}|&#125;)/g, DOC_VERSION)\n              }\n\n              if (node.children) {\n                node.children.forEach(replaceTextNodes)\n              }\n            }\n\n            replaceTextNodes(tree)\n          }\n        ],\n        highlight: {\n          highlighter: async (code: string, lang: string) => {\n            return escapeSvelte(\n              await codeToHtml(\n                code,\n                {\n                  lang,\n                  theme: 'kanagawa-wave',\n                  transformers: [addCodeblockCopyButton()],\n                  tabindex: -1\n                }\n              )\n            );\n          }\n        }\n      })\n\n      const prev = compiledContent.data.fm!.prev?.split(':')\n      const next = compiledContent.data.fm!.next?.split(':')\n\n      return {\n        url: `/docs/${docPath.slice(3)}`,\n        raw: content,\n        title: compiledContent.data.fm!.title,\n        prev: prev ? { url: prev[0], title: prev[1] } : undefined,\n        next: next ? { url: next[0], title: next[1] } : undefined,\n        html: compiledContent.code\n      }\n    }))\n\n  return docs\n}\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"Fum: A fully ricable tui-based music client\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n  };\n\n  outputs = {\n    self,\n    nixpkgs,\n    flake-utils,\n  }:\n    flake-utils.lib.eachDefaultSystem (system: let\n      pkgs = import nixpkgs {inherit system;};\n\n      updateScript = pkgs.writeShellScriptBin \"update-fum\" ''\n        nix flake update\n\n        ${pkgs.nix-update}/bin/nix-update -vr 'v(.*)' --flake --commit fum\n      '';\n    in {\n      packages.fum = pkgs.rustPlatform.buildRustPackage rec {\n        pname = \"fum\";\n        version = \"0.9.17\";\n\n        src = pkgs.fetchFromGitHub {\n          owner = \"qxb3\";\n          repo = pname;\n          rev = \"v${version}\";\n          hash = \"sha256-E9Z8bs5bdNcXHRJIkzcISIz8R1wnZu8sO6tXQp+5bpQ=\";\n        };\n\n        cargoLock = {\n          lockFile = ./Cargo.lock;\n        };\n\n        nativeBuildInputs = with pkgs; [\n          pkg-config\n          autoPatchelfHook\n        ];\n\n        buildInputs = with pkgs; [\n          openssl\n          dbus\n          libgcc\n        ];\n\n        OPENSSL_DIR = \"${pkgs.openssl.dev}\";\n        OPENSSL_LIB_DIR = \"${pkgs.openssl.out}/lib\";\n        OPENSSL_INCLUDE_DIR = \"${pkgs.openssl.dev}/include\";\n\n        meta = with pkgs.lib; {\n          description = \"A fully ricable tui-based music client\";\n          homepage = \"https://github.com/qxb3/fum\";\n          license = licenses.mit;\n          maintainers = with maintainers; [linuxmobile];\n          platforms = platforms.linux;\n        };\n      };\n\n      packages.default = self.packages.${system}.fum;\n\n      devShells.default = pkgs.mkShell {\n        inputsFrom = [self.packages.${system}.fum];\n        buildInputs = with pkgs; [\n          cargo\n          rust-analyzer\n          rustfmt\n          clippy\n          updateScript\n        ];\n      };\n\n      apps.update = {\n        type = \"app\";\n        program = \"${updateScript}/bin/update-fum\";\n      };\n\n      nixosModules.fum = import ./modules/default.nix;\n\n      homeManagerModules.fum = {\n        config,\n        pkgs,\n        lib,\n        ...\n      }:\n        import ./nix/hm-module.nix {\n          inherit config pkgs lib;\n          fumPackage = self.packages.${system}.fum;\n        };\n    });\n}\n"
  },
  {
    "path": "nix/default.nix",
    "content": "{\n  nixosModules = import ./modules/default.nix;\n  homeManagerModules = import ./hm-module.nix;\n}\n"
  },
  {
    "path": "nix/hm-module.nix",
    "content": "{\n  config,\n  lib,\n  fumPackage,\n  ...\n}: let\n  inherit (lib.types) bool package int str;\n  inherit (lib.modules) mkIf;\n  inherit (lib.options) mkOption mkEnableOption;\n\n  boolToString = x:\n    if x\n    then \"true\"\n    else \"false\";\n  cfg = config.programs.fum;\n  filterOptions = options: builtins.filter (opt: builtins.elemAt opt 1 != \"\") options;\nin {\n  options.programs.fum = {\n    enable = mkEnableOption \"Enable the fum music client.\";\n\n    package = mkOption {\n      description = \"The fum music client package.\";\n      type = package;\n      default = fumPackage;\n    };\n\n    players = mkOption {\n      description = \"List of media players to control.\";\n      type = lib.types.listOf str;\n      default = [\"spotify\"];\n    };\n\n    use_active_player = mkOption {\n      description = \"Whether to use the active player.\";\n      type = bool;\n      default = true;\n    };\n\n    align = mkOption {\n      description = \"Alignment of the UI.\";\n      type = str;\n      default = \"center\";\n    };\n\n    direction = mkOption {\n      description = \"Direction of the UI.\";\n      type = str;\n      default = \"vertical\";\n    };\n\n    flex = mkOption {\n      description = \"Flex alignment of the UI.\";\n      type = str;\n      default = \"start\";\n    };\n\n    width = mkOption {\n      description = \"Width of the UI.\";\n      type = int;\n      default = 20;\n    };\n\n    height = mkOption {\n      description = \"Height of the UI.\";\n      type = int;\n      default = 18;\n    };\n\n    layout = mkOption {\n      description = \"Layout configuration.\";\n      type = lib.types.listOf lib.types.attrs;\n      default = [];\n    };\n  };\n\n  config = mkIf cfg.enable {\n    home.packages = [cfg.package];\n\n    xdg.configFile.\"fum/config.json\".text = let\n      formatOption = name: value: ''\"${name}\": ${value}'';\n      formatConfig = options:\n        builtins.concatStringsSep \",\\n\" (map (opt:\n          formatOption (builtins.head opt)\n          (builtins.elemAt opt 1))\n        options);\n    in ''\n      {\n        ${formatConfig (filterOptions [\n        [\"players\" (builtins.toJSON cfg.players)]\n        [\"use_active_player\" (boolToString cfg.use_active_player)]\n        [\"align\" (builtins.toJSON cfg.align)]\n        [\"direction\" (builtins.toJSON cfg.direction)]\n        [\"flex\" (builtins.toJSON cfg.flex)]\n        [\"width\" (toString cfg.width)]\n        [\"height\" (toString cfg.height)]\n        [\"layout\" (builtins.toJSON cfg.layout)]\n      ])}\n      }\n    '';\n  };\n}\n"
  },
  {
    "path": "src/action.rs",
    "content": "use std::time::Duration;\n\nuse mpris::{LoopStatus, Player};\nuse serde::{de, Deserialize};\n\nuse crate::{fum::Fum, regexes::{BACKWARD_RE, FORWARD_RE, POSITION_RE, VAR_SET_RE, VAR_TOGGLE_RE, VOLUME_RE}, FumResult};\n\nmacro_rules! if_player {\n    ($player:expr, $callback:expr) => {\n        if let Some(player) = $player {\n            $callback(player)?;\n        }\n    };\n}\n\n#[derive(Debug, Clone)]\npub enum VolumeType {\n    Increase(f64),\n    Decrease(f64),\n    Set(f64)\n}\n\n#[derive(Debug, Clone)]\npub enum Action {\n    Quit,\n\n    Stop,\n    Play,\n    Pause,\n\n    Prev,\n    PlayPause,\n    Next,\n\n    ShuffleOff,\n    ShuffleToggle,\n    ShuffleOn,\n\n    LoopNone,\n    LoopPlaylist,\n    LoopTrack,\n    LoopCycle,\n\n    Forward(i64),\n    Backward(i64),\n    Position(u64),\n\n    Volume(VolumeType),\n\n    Toggle(String, String, String),\n    Set(String, String)\n}\n\nimpl<'de> Deserialize<'de> for Action {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>\n    {\n        let action_str: &str = Deserialize::deserialize(deserializer)?;\n\n        match action_str {\n            \"quit()\"            => Ok(Action::Quit),\n\n            \"stop()\"            => Ok(Action::Stop),\n            \"play()\"            => Ok(Action::Play),\n            \"pause()\"           => Ok(Action::Pause),\n\n            \"prev()\"            => Ok(Action::Prev),\n            \"play_pause()\"      => Ok(Action::PlayPause),\n            \"next()\"            => Ok(Action::Next),\n\n            \"shuffle_off()\"     => Ok(Action::ShuffleOff),\n            \"shuffle_toggle()\"  => Ok(Action::ShuffleToggle),\n            \"shuffle_on()\"      => Ok(Action::ShuffleOn),\n\n            \"loop_none()\"       => Ok(Action::LoopNone),\n            \"loop_track()\"      => Ok(Action::LoopTrack),\n            \"loop_playlist()\"   => Ok(Action::LoopPlaylist),\n            \"loop_cycle()\"      => Ok(Action::LoopCycle),\n\n            // forward() action\n            a if FORWARD_RE.is_match(a) => {\n                if let Some(captures) = FORWARD_RE.captures(a) {\n                    match captures[1].parse::<i64>() {\n                        Ok(offset) => return Ok(Action::Forward(offset)),\n                        Err(_) => return Err(de::Error::custom(\"Invalid forward() offset format\"))\n                    }\n                }\n\n                Err(de::Error::custom(\"Invalid forward() format\"))\n            },\n\n            // backward() action\n            a if BACKWARD_RE.is_match(a) => {\n                if let Some(captures) = BACKWARD_RE.captures(a) {\n                    match captures[1].parse::<i64>() {\n                        Ok(offset) => return Ok(Action::Backward(offset)),\n                        Err(_) => return Err(de::Error::custom(\"Invalid backward() offset format\"))\n                    }\n                }\n\n                Err(de::Error::custom(\"Invalid backward() format\"))\n            },\n\n            // position() action\n            a if POSITION_RE.is_match(a) => {\n                if let Some(captures) = POSITION_RE.captures(a) {\n                    match captures[1].parse::<u64>() {\n                        Ok(position) => return Ok(Action::Position(position)),\n                        Err(_) => return Err(de::Error::custom(\"Invalid position() offset format\"))\n                    }\n                }\n\n                Err(de::Error::custom(\"Invalid position() format\"))\n            },\n\n            // volume() action\n            a if VOLUME_RE.is_match(a) => {\n                if let Some(captures) = VOLUME_RE.captures(a) {\n                    match captures[1].to_string().as_str() {\n                        c if c.starts_with(\"+\") => {\n                            let value = c\n                                .replace(\"+\", \"\")\n                                .parse::<f64>()\n                                .map_err(|_| de::Error::custom(\"Failed to parse volume() value.\"))?;\n\n                            return Ok(Action::Volume(VolumeType::Increase(value.min(100.0))));\n                        },\n                        c if c.starts_with(\"-\") => {\n                            let value = c\n                                .replace(\"-\", \"\")\n                                .parse::<f64>()\n                                .map_err(|_| de::Error::custom(\"Failed to parse volume() value.\"))?;\n\n                            return Ok(Action::Volume(VolumeType::Decrease(value.min(100.0))));\n                        },\n                        c => {\n                            let value = c\n                                .parse::<f64>()\n                                .map_err(|_| de::Error::custom(\"Failed to parse volume() value.\"))?;\n\n                            return Ok(Action::Volume(VolumeType::Set(value.min(100.0))))\n                        }\n                    }\n                }\n\n                Err(de::Error::custom(\"Unknown exception while parsing volume() action\"))\n            }\n\n            // toggle() action\n            a if VAR_TOGGLE_RE.is_match(a) => {\n                if let Some(captures) = VAR_TOGGLE_RE.captures(a) {\n                    let name = captures[1].to_string();\n                    let first = captures[2].to_string();\n                    let second = captures[3].to_string();\n\n                    return Ok(Action::Toggle(name, first, second));\n                }\n\n                Err(de::Error::custom(\"Unknown exception while parsing toggle() action\"))\n            },\n\n            // set() action\n            a if VAR_SET_RE.is_match(a) => {\n                if let Some(captures) = VAR_SET_RE.captures(a) {\n                    let name = captures[1].to_string();\n                    let first = captures[2].to_string();\n\n                    return Ok(Action::Set(name, first));\n                }\n\n                Err(de::Error::custom(\"Unknown exception while parsing set() action\"))\n            },\n\n            // Error if forward() / backward() has no value inside\n            \"forward()\" => Err(de::Error::custom(format!(\"Invalid forward() format, needs value inside\"))),\n            \"backward()\" => Err(de::Error::custom(format!(\"Invalid backward() format, needs value inside\"))),\n\n            _ => Err(de::Error::custom(format!(\"Unknown action: {}\", action_str)))\n        }\n    }\n}\n\nimpl Action {\n    pub fn run(action: &Action, fum: &mut Fum) -> FumResult<()> {\n        match action {\n            Action::Quit            => fum.exit = true,\n\n            Action::Stop            => if_player!(&fum.player, |player: &Player| player.stop()),\n            Action::Play            => if_player!(&fum.player, |player: &Player| player.play()),\n            Action::Pause           => if_player!(&fum.player, |player: &Player| player.pause()),\n\n            Action::Prev            => if_player!(&fum.player, |player: &Player| player.previous()),\n            Action::PlayPause       => if_player!(&fum.player, |player: &Player| player.play_pause()),\n            Action::Next            => if_player!(&fum.player, |player: &Player| player.next()),\n\n            Action::ShuffleOff      => if_player!(&fum.player, |player: &Player| player.set_shuffle(true)),\n            Action::ShuffleToggle   => if_player!(&fum.player, |player: &Player| player.set_shuffle(!player.get_shuffle()?)),\n            Action::ShuffleOn       => if_player!(&fum.player, |player: &Player| player.set_shuffle(false)),\n\n            Action::LoopNone        => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::None)),\n            Action::LoopPlaylist    => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::Playlist)),\n            Action::LoopTrack       => if_player!(&fum.player, |player: &Player| player.set_loop_status(LoopStatus::Track)),\n            Action::LoopCycle       => {\n                if let Some(player) = &fum.player {\n                    let loop_status = player.get_loop_status()?;\n\n                    match loop_status {\n                        LoopStatus::None        => player.set_loop_status(LoopStatus::Playlist)?,\n                        LoopStatus::Playlist    => player.set_loop_status(LoopStatus::Track)?,\n                        LoopStatus::Track       => player.set_loop_status(LoopStatus::None)?\n                    }\n                }\n            },\n\n            Action::Forward(offset)     => if_player!(&fum.player, |player: &Player| {\n                fum.redraw = true;\n\n                // if offset is -1, set position to music length\n                if *offset == -1 {\n                    if let Some(track_id) = &fum.state.meta.track_id {\n                        return player.set_position(track_id.clone(), &fum.state.meta.length)\n                    }\n                }\n\n                player.seek_forwards(&Duration::from_millis(*offset as u64))\n            }),\n            Action::Backward(offset)     => if_player!(&fum.player, |player: &Player| {\n                fum.redraw = true;\n\n                // if offset is -1, set position to music start\n                if *offset == -1 {\n                    if let Some(track_id) = &fum.state.meta.track_id {\n                        return player.set_position(track_id.clone(), &Duration::from_secs(0))\n                    }\n                }\n\n                player.seek_backwards(&Duration::from_millis(*offset as u64))\n            }),\n            Action::Position(position) => {\n                fum.redraw = true;\n\n                if let Some(player) = &fum.player {\n                    if let Some(track_id) = &fum.state.meta.track_id {\n                        player.set_position(track_id.clone(), &Duration::from_secs(*position))?;\n                    }\n                }\n            }\n\n            Action::Volume(volume_type)       => if_player!(&fum.player, |player: &Player| {\n                fum.redraw = true;\n\n                let current_volume = player.get_volume().unwrap_or(0.0) * 100.0;\n\n                match volume_type {\n                    VolumeType::Increase(value) => player.set_volume(((current_volume + *value) / 100.0).min(1.0)),\n                    VolumeType::Decrease(value) => player.set_volume(((current_volume - *value) / 100.0).min(1.0)),\n                    VolumeType::Set(value) => player.set_volume((*value / 100.0).min(1.0))\n                }\n            }),\n\n            Action::Toggle(name, first, second) => {\n                fum.redraw = true;\n\n                if let Some(current) = &fum.state.vars.get(name) {\n                    if *current == first {\n                        fum.state.vars.insert(name.to_string(), second.to_string());\n                    } else {\n                        fum.state.vars.insert(name.to_string(), first.to_string());\n                    }\n                }\n            },\n            Action::Set(name, first) => {\n                fum.redraw = true;\n\n                // Just checks wether var exists, don't care about the value\n                if fum.state.vars.get(name).is_some() {\n                    fum.state.vars.insert(name.to_string(), first.to_string());\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/cli.rs",
    "content": "use clap::{Parser, Subcommand};\nuse expanduser::expanduser;\n\nuse crate::{config::{Align, Config}, fum::FumResult};\n\n#[derive(Subcommand)]\npub enum Commands {\n    ListPlayers\n}\n\n#[derive(Parser)]\n#[command(name = \"fum\", version, about)]\npub struct FumCli {\n    #[arg(short, long, value_name = \"config file\", default_value = \"~/.config/fum/config.jsonc\")]\n    config: Option<String>,\n\n    #[arg(short, long, value_name = \"string[]\", value_delimiter = ',')]\n    players: Option<Vec<String>>,\n\n    #[arg(long, value_name = \"boolean\")]\n    use_active_player: Option<bool>,\n\n    #[arg(long, value_name = \"number\")]\n    fps: Option<u64>,\n\n    #[arg(short, long, value_name = \"center,top,left,bottom,right,top-left,top-right,bottom-left,bottom-right\")]\n    align: Option<String>,\n\n    #[command(subcommand)]\n    pub command: Option<Commands>\n}\n\npub fn run() -> FumResult<(FumCli, Config)> {\n    let fum_cli = FumCli::parse();\n\n    let config_path = expanduser(fum_cli.config.as_ref().unwrap())\n        .map_err(|err| format!(\"Failed to expand path: {err}\"))?;\n\n    let mut config = Config::load(&config_path)?;\n\n    if let Some(players) = fum_cli.players.as_ref() {\n        config.players = players.to_owned();\n    }\n\n    if let Some(use_active_player) = fum_cli.use_active_player.as_ref() {\n        config.use_active_player = use_active_player.to_owned();\n    }\n\n    if let Some(fps) = fum_cli.fps.as_ref() {\n        config.fps = fps.to_owned();\n    }\n\n    if let Some(align) = fum_cli.align.as_ref() {\n        let align = Align::from_str(align.as_str())\n            .ok_or(\"Invalid value for 'align'\".to_string())?;\n\n        config.align = align;\n    }\n\n    Ok((fum_cli, config))\n}\n"
  },
  {
    "path": "src/config/config.rs",
    "content": "use std::{collections::HashMap, fs, path::PathBuf};\nuse expanduser::expanduser;\nuse ratatui::style::Color;\nuse serde::Deserialize;\n\nuse crate::{action::Action, fum::FumResult, regexes::JSONC_COMMENT_RE, widget::{ContainerFlex, Direction, FumWidget}};\n\nuse super::{defaults::{align, bg, border, cover_art_ascii, direction, fg, flex, fps, height, keybinds, layout, padding, players, use_active_player, width}, keybind::Keybind};\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum Align {\n    Center,\n    Top,\n    Left,\n    Bottom,\n    Right,\n    #[serde(rename = \"top-left\")]\n    TopLeft,\n    #[serde(rename = \"top-right\")]\n    TopRight,\n    #[serde(rename = \"bottom-left\")]\n    BottomLeft,\n    #[serde(rename = \"bottom-right\")]\n    BottomRight,\n}\n\nimpl Align {\n    pub fn from_str(str: &str) -> Option<Self> {\n        match str {\n            \"center\"        => Some(Self::Center),\n            \"top\"           => Some(Self::Top),\n            \"left\"          => Some(Self::Left),\n            \"bottom\"        => Some(Self::Bottom),\n            \"top-left\"      => Some(Self::TopLeft),\n            \"top-right\"     => Some(Self::TopRight),\n            \"bottom-left\"   => Some(Self::BottomLeft),\n            \"bottom-right\"  => Some(Self::BottomRight),\n            _               => None\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct Config {\n    #[serde(default = \"players\")]\n    pub players: Vec<String>,\n\n    #[serde(default = \"use_active_player\")]\n    pub use_active_player: bool,\n\n    #[serde(default = \"fps\")]\n    pub fps: u64,\n\n    #[serde(default = \"keybinds\")]\n    pub keybinds: HashMap<Keybind, Action>,\n\n    #[serde(default = \"align\")]\n    pub align: Align,\n\n    #[serde(default = \"direction\")]\n    pub direction: Direction,\n\n    #[serde(default = \"flex\")]\n    pub flex: ContainerFlex,\n\n    #[serde(default = \"width\")]\n    pub width: u16,\n\n    #[serde(default = \"height\")]\n    pub height: u16,\n\n    #[serde(default = \"border\")]\n    pub border: bool,\n\n    #[serde(default = \"padding\")]\n    pub padding: [u16; 2],\n\n    #[serde(default = \"bg\")]\n    pub bg: Color,\n\n    #[serde(default = \"fg\")]\n    pub fg: Color,\n\n    #[serde(default = \"cover_art_ascii\")]\n    pub cover_art_ascii: String,\n\n    #[serde(default = \"layout\")]\n    pub layout: Vec<FumWidget>,\n}\n\nimpl Default for Config {\n    fn default() -> Self {\n        Self {\n            players: players(),\n            use_active_player: use_active_player(),\n            fps: fps(),\n            keybinds: keybinds(),\n            align: align(),\n            direction: direction(),\n            flex: flex(),\n            width: width(),\n            height: height(),\n            border: border(),\n            padding: padding(),\n            bg: bg(),\n            fg: fg(),\n            cover_art_ascii: cover_art_ascii(),\n            layout: layout()\n        }\n    }\n}\n\nimpl Config {\n    pub fn load(path: &PathBuf) -> FumResult<Self> {\n        match fs::read_to_string(path) {\n            Ok(config_file) => {\n                // Clean config file for comments\n                let cleaned_config_file = JSONC_COMMENT_RE.replace_all(&config_file, \"\")\n                    .to_string();\n\n                let mut config: Config = serde_json::from_str(&cleaned_config_file)\n                    .map_err(|err| format!(\"Failed to parse config: {err}\"))?;\n\n                // Get expanded path of cover_art_ascii\n                let cover_art_ascii_path = expanduser(&config.cover_art_ascii)\n                    .map_err(|err| format!(\"Failed to expand path of cover_art_ascii: {err}\"))?;\n\n                // Load the cover_art_ascii\n                match fs::read_to_string(cover_art_ascii_path) {\n                    Ok(cover_art_ascii) => config.cover_art_ascii = cover_art_ascii,\n                    Err(_) => config.cover_art_ascii = \"\".to_string()\n                }\n\n                // Convert fps into millis\n                config.fps = 1000 / config.fps;\n\n                Ok(config)\n            },\n            Err(_) => Ok(Config::default())\n        }\n    }\n}\n\n"
  },
  {
    "path": "src/config/defaults.rs",
    "content": "use std::collections::HashMap;\n\nuse ratatui::style::Color;\n\nuse crate::{action::Action, utils::widget::generate_id, widget::{ContainerFlex, CoverArtResize, Direction, FumWidget, LabelAlignment, ProgressOption}};\n\nuse super::{keybind::Keybind, Align};\n\npub fn players() -> Vec<String> { vec![\"spotify\".to_string()] }\npub fn use_active_player() -> bool { false }\npub fn fps() -> u64 { 10 }\n\npub fn align() -> Align { Align::Center }\npub fn direction() -> Direction { Direction::Vertical }\npub fn flex() -> ContainerFlex { ContainerFlex::Start }\n\npub fn width() -> u16 { 19 }\npub fn height() -> u16 { 15 }\n\npub fn border() -> bool { false }\n\npub fn padding() -> [u16; 2] { [0, 0] }\n\npub fn bg() -> Color { Color::Reset }\npub fn fg() -> Color { Color::Reset }\n\npub fn cover_art_ascii() -> String { \"\".to_string() }\n\npub fn keybinds() -> HashMap<Keybind, Action> {\n    HashMap::from([\n        (Keybind::Many([Keybind::Esc, Keybind::Char('q')].to_vec()), Action::Quit),\n        (Keybind::Char('h'), Action::Prev),\n        (Keybind::Char('l'), Action::Next),\n        (Keybind::Char(' '), Action::PlayPause)\n    ])\n}\n\npub fn layout() -> Vec<FumWidget> {\n    Vec::from([\n        FumWidget::CoverArt {\n            width: None,\n            height: None,\n            border: false,\n            resize: CoverArtResize::Scale,\n            bg: None,\n            fg: None,\n        },\n        FumWidget::Empty {\n            size: 1,\n            bg: None,\n            fg: None\n        },\n        FumWidget::Container {\n            width: None,\n            height: None,\n            direction: Direction::Vertical,\n            border: false,\n            padding: padding(),\n            flex: ContainerFlex::default(),\n            bg: None,\n            fg: None,\n            children: Vec::from([\n                FumWidget::Label {\n                    text: \"$title\".to_string(),\n                    direction: Direction::default(),\n                    align: LabelAlignment::Center,\n                    truncate: true,\n                    bold: false,\n                    bg: None,\n                    fg: None\n                },\n                FumWidget::Label {\n                    text: \"$artists\".to_string(),\n                    direction: Direction::default(),\n                    align: LabelAlignment::Center,\n                    truncate: true,\n                    bold: false,\n                    bg: None,\n                    fg: None\n                },\n                FumWidget::Empty {\n                    size: 1,\n                    bg: None,\n                    fg: None\n                },\n                FumWidget::Container {\n                    width: None,\n                    height: Some(1),\n                    direction: Direction::Horizontal,\n                    border: false,\n                    padding: padding(),\n                    flex: ContainerFlex::SpaceAround,\n                    bg: None,\n                    fg: None,\n                    children: Vec::from([\n                        FumWidget::Button {\n                            id: generate_id(),\n                            text: \"󰒮\".to_string(),\n                            direction: Direction::default(),\n                            action: Some(Action::Prev),\n                            action_secondary: None,\n                            exec: None,\n                            bold: false,\n                            bg: None,\n                            fg: None\n                        },\n                        FumWidget::Button {\n                            id: generate_id(),\n                            text: \"$status-icon\".to_string(),\n                            direction: Direction::default(),\n                            action: Some(Action::PlayPause),\n                            action_secondary: None,\n                            exec: None,\n                            bold: false,\n                            bg: None,\n                            fg: None\n                        },\n                        FumWidget::Button {\n                            id: generate_id(),\n                            text: \"󰒭\".to_string(),\n                            direction: Direction::default(),\n                            action: Some(Action::Next),\n                            action_secondary: None,\n                            exec: None,\n                            bold: false,\n                            bg: None,\n                            fg: None\n                        }\n                    ])\n                },\n                FumWidget::Progress {\n                    id: generate_id(),\n                    size: None,\n                    direction: Direction::Horizontal,\n                    progress: ProgressOption {\n                        char: '󰝤',\n                        bg: None,\n                        fg: None\n                    },\n                    empty: ProgressOption {\n                        char: '󰁱',\n                        bg: None,\n                        fg: None\n                    }\n                },\n                FumWidget::Container {\n                    width: None,\n                    height: Some(1),\n                    border: false,\n                    padding: padding(),\n                    direction: Direction::Horizontal,\n                    flex: ContainerFlex::SpaceBetween,\n                    bg: None,\n                    fg: None,\n                    children: Vec::from([\n                        FumWidget::Label {\n                            text: \"$position\".to_string(),\n                            direction: Direction::default(),\n                            align: LabelAlignment::Left,\n                            truncate: false,\n                            bold: false,\n                            bg: None,\n                            fg: None\n                        },\n                        FumWidget::Label {\n                            text: \"$length\".to_string(),\n                            direction: Direction::default(),\n                            align: LabelAlignment::Right,\n                            truncate: false,\n                            bold: false,\n                            bg: None,\n                            fg: None\n                        }\n                    ])\n                }\n            ])\n        }\n    ])\n}\n"
  },
  {
    "path": "src/config/keybind.rs",
    "content": "use crossterm::event::{KeyCode, KeyModifiers};\nuse serde::{de, Deserialize};\n\n#[derive(Debug, Clone, PartialEq, Eq, Hash)]\npub enum Keybind {\n    Backspace,\n    Enter,\n    Left,\n    Up,\n    Right,\n    Down,\n    End,\n    PageUp,\n    PageDown,\n    Tab,\n    BackTab,\n    Delete,\n    Insert,\n    Esc,\n    Caps,\n    F(u8),\n    Char(char),\n    Many(Vec<Keybind>),\n    WithModifier(KeyModifiers, Box<Keybind>)\n}\n\nimpl<'de> Deserialize<'de> for Keybind {\n    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>\n    where\n        D: serde::Deserializer<'de>\n    {\n        let keybind: &str = Deserialize::deserialize(deserializer)?;\n\n        if keybind.contains(\";\") {\n            let keybinds = keybind\n                .split(\";\")\n                .filter(|k| !k.is_empty())\n                .map(|k| Keybind::parse_keybind(k.trim()))\n                .collect::<Result<Vec<Keybind>, D::Error>>()?;\n\n            return Ok(Keybind::Many(keybinds));\n        }\n\n        Keybind::parse_keybind(keybind)\n    }\n}\n\nimpl Keybind {\n    pub fn into_keycode(&self) -> KeyCode {\n        match self {\n            Keybind::Backspace           => KeyCode::Backspace,\n            Keybind::Enter               => KeyCode::Enter,\n            Keybind::Left                => KeyCode::Left,\n            Keybind::Up                  => KeyCode::Up,\n            Keybind::Right               => KeyCode::Right,\n            Keybind::Down                => KeyCode::Down,\n            Keybind::End                 => KeyCode::End,\n            Keybind::PageUp              => KeyCode::PageUp,\n            Keybind::PageDown            => KeyCode::PageDown,\n            Keybind::Tab                 => KeyCode::Tab,\n            Keybind::BackTab             => KeyCode::BackTab,\n            Keybind::Delete              => KeyCode::Delete,\n            Keybind::Insert              => KeyCode::Insert,\n            Keybind::Esc                 => KeyCode::Esc, Keybind::Caps                => KeyCode::CapsLock,\n            Keybind::F(u8)               => KeyCode::F(*u8),\n            Keybind::Char(char)          => KeyCode::Char(*char),\n            Keybind::Many(_)             => unreachable!(),\n            Keybind::WithModifier(_, _)  => unreachable!()\n        }\n    }\n\n    fn parse_keybind<D>(keybind: &str) -> Result<Keybind, D>\n    where\n        D: de::Error\n    {\n        match keybind {\n            \"backspace\"     => Ok(Keybind::Backspace),\n            \"enter\"         => Ok(Keybind::Enter),\n            \"left\"          => Ok(Keybind::Left),\n            \"up\"            => Ok(Keybind::Up),\n            \"right\"         => Ok(Keybind::Right),\n            \"down\"          => Ok(Keybind::Down),\n            \"end\"           => Ok(Keybind::End),\n            \"page_up\"       => Ok(Keybind::PageUp),\n            \"page_down\"     => Ok(Keybind::PageDown),\n            \"tab\"           => Ok(Keybind::Tab),\n            \"back_tab\"      => Ok(Keybind::BackTab),\n            \"delete\"        => Ok(Keybind::Delete),\n            \"insert\"        => Ok(Keybind::Insert),\n            \"caps\"          => Ok(Keybind::Caps),\n            \"esc\"           => Ok(Keybind::Esc),\n\n            // Fn keys.\n            k if k.starts_with('f') => {\n                match k[1..].parse::<u8>() {\n                    Ok(fn_num) => Ok(Keybind::F(fn_num)),\n                    Err(_) => Err(de::Error::custom(\"Invalid fn key format\"))\n                }\n            },\n\n            // Individual key.\n            k if k.len() == 1 => {\n                match k.chars().next() {\n                    Some(char) => Ok(Keybind::Char(char)),\n                    None => Err(de::Error::custom(format!(\"Invalid keyboard key: {k}\")))\n                }\n            },\n\n            k if k.contains(\"+\") => {\n                let split = k.split('+');\n                let len = split.to_owned().count();\n\n                let modifier_keys = split.to_owned().take(len.saturating_sub(1)).collect::<Vec<&str>>();\n                let key = split.to_owned().last().unwrap();\n\n                let mut modifiers = KeyModifiers::NONE;\n\n                for modifier in modifier_keys {\n                    let modifier = match modifier {\n                        \"shift\" => KeyModifiers::SHIFT,\n                        \"ctrl\" => KeyModifiers::CONTROL,\n                        \"alt\" => KeyModifiers::ALT,\n                        \"super\" => KeyModifiers::SUPER,\n                        \"hyper\" => KeyModifiers::HYPER,\n                        \"meta\" => KeyModifiers::META,\n                        _ => return Err(de::Error::custom(format!(\"Unknown key modifier: {modifier}\")))\n                    };\n\n                    modifiers = modifiers | modifier;\n                }\n\n                let keybind = Keybind::parse_keybind(key)?;\n\n                Ok(Keybind::WithModifier(modifiers, Box::new(keybind)))\n            },\n\n            _ => Err(de::Error::custom(format!(\"Unknown keybind: {keybind}\")))\n        }\n    }\n}\n"
  },
  {
    "path": "src/config/mod.rs",
    "content": "mod config;\nmod defaults;\nmod keybind;\n\npub use config::*;\npub use keybind::*;\n"
  },
  {
    "path": "src/fum.rs",
    "content": "use core::error;\nuse std::{io::{stdout, Stdout}, process::{Command, Stdio}, time::Duration};\n\nuse crossterm::{event::{self, EnableMouseCapture, Event, KeyEventKind, MouseButton, MouseEventKind}, execute};\nuse mpris::Player;\nuse ratatui::{layout::Position, prelude::CrosstermBackend, Terminal};\nuse ratatui_image::picker::Picker;\n\nuse crate::{action::{Action, VolumeType}, config::{Config, Keybind}, meta::Meta, state::FumState, ui::Ui, utils, widget::{Direction, SliderSource}};\n\npub type FumResult<T> = std::result::Result<T, Box<dyn error::Error>>;\n\npub struct Fum<'a> {\n    config: &'a Config,\n    pub terminal: Terminal<CrosstermBackend<Stdout>>,\n    pub ui: Ui<'a>,\n    pub picker: Picker,\n    pub player: Option<Player>,\n    pub state: FumState,\n\n    // drag state\n    pub dragging: bool,\n    pub start_drag: Option<Position>,\n    pub current_drag: Option<Position>,\n    pub drag_action: Option<Action>,\n\n    pub redraw: bool,\n    pub exit: bool\n}\n\nimpl<'a> Fum<'a> {\n    pub fn new(config: &'a Config) -> FumResult<Self> {\n        let player = Meta::get_player(&config).ok();\n\n        let picker = Picker::from_query_stdio()?;\n\n        let meta = match &player {\n            Some(player) => Meta::fetch(player, &picker, None).unwrap_or(Meta::default()),\n            None => Meta::default()\n        };\n\n        // Enable mouse capture\n        execute!(stdout(), EnableMouseCapture)?;\n\n        Ok(Self {\n            config,\n            terminal: ratatui::init(),\n            ui: Ui::new(config),\n            picker,\n            player,\n            state: FumState::new(meta),\n\n            // drag state\n            dragging: false,\n            start_drag: None,\n            current_drag: None,\n            drag_action: None,\n\n            redraw: true, // Draw at startup\n            exit: false\n        })\n    }\n\n    pub fn run(&mut self) -> FumResult<()> {\n        while !self.exit {\n            if self.redraw {\n                self.terminal.draw(|frame| {\n                    self.ui.draw(frame, &mut self.state);\n                    self.redraw = false;\n                })?;\n            }\n\n            self.update_meta();\n            self.term_events()?;\n        }\n\n        utils::terminal::restore();\n\n        Ok(())\n    }\n\n    fn term_events(&mut self) -> FumResult<()> {\n        if event::poll(Duration::from_millis(self.config.fps))? {\n            let event = event::read()?;\n\n            match event {\n                Event::Key(key) if key.kind == KeyEventKind::Press => {\n                    for (keybind, action) in self.config.keybinds.iter() {\n                        match keybind {\n                            Keybind::Many(keybinds) => {\n                                for keybind in keybinds {\n                                    if let Keybind::WithModifier(modifiers, keybind) = keybind {\n                                        if key.modifiers.intersects(*modifiers) {\n                                            if key.code == keybind.into_keycode() {\n                                                Action::run(action, self)?;\n                                            }\n                                        }\n                                    } else {\n                                        if key.code == keybind.into_keycode() {\n                                            Action::run(action, self)?;\n                                        }\n                                    }\n                                }\n                            },\n                            Keybind::WithModifier(modifiers, keybind) => {\n                                if key.modifiers.intersects(*modifiers) {\n                                    if key.code == keybind.into_keycode() {\n                                        Action::run(action, self)?;\n                                    }\n                                }\n                            },\n                            keybind => {\n                                if key.code == keybind.into_keycode() {\n                                    Action::run(action, self)?;\n                                }\n                            }\n                        }\n                    }\n                },\n                Event::Mouse(mouse) => {\n                    match mouse.kind {\n                        // Button click mouse left.\n                        MouseEventKind::Down(MouseButton::Left) => {\n                            if let Some((action, _, exec)) = self.ui.click(mouse.column, mouse.row, &self.state.buttons) {\n                                let action = action.to_owned();\n                                let exec = exec.to_owned();\n\n                                if let Some(action) = action {\n                                    Action::run(&action, self)?;\n                                }\n\n                                if let Some(exec) = exec {\n                                    let parts: Vec<&str> = exec.split_whitespace().collect();\n                                    if let Some(command) = parts.get(0) {\n                                        let _ = Command::new(command) // Ignore result\n                                            .args(&parts[1..])\n                                            .stdout(Stdio::null())\n                                            .stderr(Stdio::null())\n                                            .spawn();\n                                    }\n                                }\n                            }\n                        },\n\n                        // Button click mouse right.\n                        MouseEventKind::Down(MouseButton::Right) => {\n                            if let Some((_, action_secondary, _)) = self.ui.click(mouse.column, mouse.row, &self.state.buttons) {\n                                let action_secondary = action_secondary.to_owned();\n\n                                if let Some(action_secondary) = action_secondary {\n                                    Action::run(&action_secondary, self)?;\n                                }\n                            }\n                        },\n\n                        // Mouse left drag.\n                        MouseEventKind::Drag(MouseButton::Left) => {\n                            if !self.dragging && self.start_drag.is_none() {\n                                self.dragging = true;\n                                self.start_drag = Some(Position::new(mouse.column, mouse.row));\n                            }\n\n                            if self.dragging {\n                                self.current_drag = Some(Position::new(mouse.column, mouse.row));\n\n                                if let Some(start_drag) = &self.start_drag {\n                                    if let Some(current_drag) = &self.current_drag {\n                                        if let Some((rect, direction, widget)) = self.ui.drag(start_drag, &self.state.sliders) {\n                                            let value: f64 = match direction {\n                                                Direction::Horizontal => ((current_drag.x as f64 - rect.x as f64) / rect.width as f64).clamp(0.0, 1.0),\n                                                Direction::Vertical => (1.0 - ((current_drag.y as f64 - rect.y as f64) / rect.height as f64)).clamp(0.0, 1.0)\n                                            };\n\n                                            match widget {\n                                                SliderSource::Progress => {\n                                                    let position = value * self.state.meta.length.as_secs() as f64;\n                                                    if position >= self.state.meta.length.as_secs() as f64 {\n                                                        self.dragging = false;\n                                                        self.start_drag = None;\n                                                        self.current_drag = None;\n                                                    }\n\n                                                    Action::run(&Action::Position(position as u64), self)?;\n                                                },\n                                                SliderSource::Volume => {\n                                                    let volume = value * 100.0;\n                                                    Action::run(&Action::Volume(VolumeType::Set(volume)), self)?;\n                                                }\n                                            }\n                                        }\n                                    }\n                                }\n                            }\n                        },\n\n                        // Mouse left release.\n                        MouseEventKind::Up(MouseButton::Left) => {\n                            self.dragging = false;\n                            self.start_drag = None;\n                            self.current_drag = None;\n                        },\n\n                        _ => {}\n                    }\n                },\n                Event::Resize(_, _) => {\n                    self.redraw = true;\n                }\n                _ => {}\n            }\n        }\n\n        Ok(())\n    }\n\n    fn update_meta(&mut self) {\n        if let Some(player) = &self.player {\n            let meta = Meta::fetch(player, &self.picker, Some(&self.state.meta))\n                .unwrap_or(Meta::default());\n\n            self.redraw = meta.changed;\n            self.state.meta = meta;\n\n            return;\n        }\n\n        self.player = Meta::get_player(&self.config).ok();\n        self.state.meta = Meta::default();\n    }\n}\n"
  },
  {
    "path": "src/main.rs",
    "content": "mod cli;\nmod fum;\nmod state;\nmod meta;\nmod ui;\nmod utils;\nmod text;\nmod widget;\nmod config;\nmod action;\nmod regexes;\n\nuse fum::{Fum, FumResult};\nuse mpris::PlayerFinder;\n\nfn main() -> FumResult<()> {\n    let (cli, config) = cli::run()?;\n\n    if let Some(command) = &cli.command {\n        match command {\n            cli::Commands::ListPlayers => {\n                let player_finder = PlayerFinder::new()\n                    .map_err(|err| format!(\"Failed to connect to D-Bus: {err}.\"))?;\n\n                let players = player_finder\n                    .find_all()\n                    .map_err(|err| format!(\"There is no any active players: {err}.\"))?;\n\n                println!(\"Active Players:\");\n                for player in players {\n                    let identity = player.identity().to_lowercase();\n                    let bus_name = player.bus_name();\n\n                    println!(\"* {identity} ~> {bus_name}\");\n                }\n\n                return Ok(());\n            }\n        }\n    }\n\n    Fum::new(&config)?\n        .run()?;\n\n    Ok(())\n}\n"
  },
  {
    "path": "src/meta.rs",
    "content": "use std::{fs, io::{self, Cursor}, str::FromStr, time::Duration};\n\nuse base64::{prelude::BASE64_STANDARD, Engine};\nuse image::ImageReader;\nuse mpris::{Metadata, MetadataValue, PlaybackStatus, Player, PlayerFinder, TrackID};\nuse ratatui_image::{picker::Picker, protocol::StatefulProtocol};\nuse reqwest::{header::RANGE, Url};\n\nuse crate::{config::Config, fum::FumResult};\n\n#[derive(Clone)]\npub struct CoverArt {\n    pub url: String,\n    pub image: StatefulProtocol\n}\n\n#[derive(Clone)]\npub struct Meta {\n    pub metadata: Metadata,\n    pub track_id: Option<TrackID>,\n    pub title: String,\n    pub artists: Vec<String>,\n    pub album: String,\n    pub status: PlaybackStatus,\n    pub status_icon: char,\n    pub status_text: String,\n    pub position: Duration,\n    pub length: Duration,\n    pub volume: f64,\n    pub cover_art: Option<CoverArt>,\n    pub changed: bool\n}\n\nimpl Default for Meta {\n    fn default() -> Self {\n        Self {\n            metadata: Metadata::default(),\n            track_id: None,\n            title: \"No Music\".to_string(),\n            artists: vec![\"Artist\".to_string()],\n            album: \"Album\".to_string(),\n            status: PlaybackStatus::Stopped,\n            status_icon: Meta::get_status_icon(&PlaybackStatus::Stopped),\n            status_text: \"stopped\".to_string(),\n            position: Duration::from_secs(0),\n            length: Duration::from_secs(0),\n            volume: 0.0,\n            cover_art: None,\n            changed: false\n        }\n    }\n}\n\nimpl Meta {\n    pub fn fetch(player: &Player, picker: &Picker, current: Option<&Self>) -> FumResult<Self> {\n        let metadata = Meta::get_metadata(player)?;\n        let track_id = Meta::get_trackid(&metadata).ok();\n        let title = Meta::get_title(&metadata)?;\n        let artists = Meta::get_artists(&metadata).unwrap_or(vec![\"Artist\".to_string()]);\n        let album = Meta::get_album(&metadata).unwrap_or(\"Album\".to_string());\n        let status = Meta::get_status(player)?;\n        let status_icon = Meta::get_status_icon(&status);\n        let status_text = Meta::get_status_text(&status);\n        let position = Meta::get_position(player)?;\n        let length = Meta::get_length(&metadata)?;\n        let volume = Meta::get_volume(player).unwrap_or(0.0);\n        let cover_art = Meta::get_cover_art(&metadata, picker, current).ok();\n\n        let mut changed = false;\n\n        if let Some(current) = &current {\n            if current.title != title ||\n            current.artists != artists ||\n            current.status != status ||\n            current.length != length ||\n            current.volume != volume ||\n            position.as_secs() > current.position.as_secs() ||\n            position.as_secs() < current.position.as_secs() {\n                changed = true;\n            }\n        }\n\n        Ok(Self {\n            metadata,\n            track_id,\n            title,\n            artists,\n            album,\n            status,\n            status_icon,\n            status_text,\n            position,\n            length,\n            volume,\n            cover_art,\n            changed\n        })\n    }\n\n    pub fn get_player(config: &Config) -> FumResult<Player> {\n        let finder = PlayerFinder::new()\n            .map_err(|err| format!(\"Failed to connect to D-Bus: {:?}.\", err))?;\n\n        let players = finder\n            .find_all()\n            .map_err(|err| format!(\"There is no any active players: {:?}.\", err))?;\n\n        for player in players {\n            let identity = player.identity().to_lowercase();\n            let bus_name = player.bus_name().to_lowercase();\n\n            if config.players.iter().any(|p|\n                p.to_lowercase() == identity.to_lowercase() ||\n                bus_name.starts_with(&p.to_lowercase())\n            ) {\n                return Ok(player);\n            }\n        }\n\n        // Find the most likely player to be used\n        if config.use_active_player {\n            let active = finder.find_active()\n                .map_err(|err| format!(\"'use-active-player' is set to true but failed to get active player: {err}\"))?;\n\n            return Ok(active);\n        }\n\n        Err(Box::new(\n            io::Error::new(\n                io::ErrorKind::Other,\n                \"Failed to find any specified players\"\n            )\n        ))\n    }\n\n    pub fn get_metadata(player: &Player) -> FumResult<Metadata> {\n        let metadata = player.get_metadata()?;\n        Ok(metadata)\n    }\n\n    pub fn get_trackid(metadata: &Metadata) -> FumResult<TrackID> {\n        let trackid = metadata.track_id()\n            .ok_or(\"Failed to get track_id\")?;\n\n        Ok(trackid)\n    }\n\n    pub fn get_title(metadata: &Metadata) -> FumResult<String> {\n        let title = metadata\n            .title()\n            .map(|t| t.to_string())\n            .ok_or(\"Failed to get xesam:title\")?;\n\n        Ok(title)\n    }\n\n    pub fn get_artists(metadata: &Metadata) -> FumResult<Vec<String>> {\n        let metadata = metadata\n            .artists()\n            .map(|a| a.iter().map(|a| a.to_string()).collect())\n            .ok_or(\"Failed to get xesam:title.\".to_string())?;\n\n        Ok(metadata)\n    }\n\n    pub fn get_status(player: &Player) -> FumResult<PlaybackStatus> {\n        let status = player\n            .get_playback_status()\n            .map_err(|err| format!(\"Failed to get player playback_status: {err}\"))?;\n\n        Ok(status)\n    }\n\n    pub fn get_status_icon(status: &PlaybackStatus) -> char {\n        match status {\n            PlaybackStatus::Stopped => '󰓛',\n            PlaybackStatus::Playing => '󰏤',\n            PlaybackStatus::Paused  => '󰐊'\n        }\n    }\n\n    pub fn get_status_text(status: &PlaybackStatus) -> String {\n        match status {\n            PlaybackStatus::Stopped => \"stopped\".to_string(),\n            PlaybackStatus::Playing => \"playing\".to_string(),\n            PlaybackStatus::Paused  => \"paused\".to_string()\n        }\n    }\n\n    pub fn get_position(player: &Player) -> FumResult<Duration> {\n        let position = player.get_position()\n            .map_err(|err| format!(\"Failed to get player position: {err}\"))?;\n\n        Ok(position)\n    }\n\n    pub fn get_length(metadata: &Metadata) -> FumResult<Duration> {\n        let length = metadata\n            .length()\n            .ok_or(\"Failed to get mpris:length\".to_string())?;\n\n        Ok(length)\n    }\n\n    pub fn get_album(metadata: &Metadata) -> FumResult<String> {\n        let album = metadata\n            .album_name()\n            .map(|a| a.to_string())\n            .ok_or(\"Failed to get xesam:album\".to_string())?;\n\n        Ok(album)\n    }\n\n    pub fn get_volume(player: &Player) -> FumResult<f64> {\n        let volume = player.get_volume()\n            .map_err(|err| format!(\"Failed to get player volume: {err}\"))?;\n\n        Ok(volume)\n    }\n\n    pub fn get_custom_meta(metadata: &Metadata, key: String) -> String {\n        let value = metadata.get(&key);\n\n        match value {\n            Some(value) => match value {\n                MetadataValue::String(str) => str.to_string(),\n                MetadataValue::Bool(bool) => bool.to_string(),\n\n                MetadataValue::U8(u8) => u8.to_string(),\n                MetadataValue::U16(u16) => u16.to_string(),\n                MetadataValue::U32(u32) => u32.to_string(),\n                MetadataValue::U64(u64) => u64.to_string(),\n\n                MetadataValue::I16(i16) => i16.to_string(),\n                MetadataValue::I32(i32) => i32.to_string(),\n                MetadataValue::I64(i64) => i64.to_string(),\n\n                MetadataValue::F64(f64) => f64.to_string(),\n\n                MetadataValue::Unsupported | _ => \"!Unsupported\".to_string()\n            },\n            None => \"!NotFound\".to_string()\n        }\n    }\n\n    pub fn get_cover_art(metadata: &Metadata, picker: &Picker, current: Option<&Meta>) -> FumResult<CoverArt> {\n        let art_url = metadata\n            .get(\"mpris:artUrl\")\n            .ok_or(\"Failed to get mpris:artUrl\")?;\n\n        if let mpris::MetadataValue::String(art_url) = art_url {\n            if let Some(current) = &current {\n                if let Some(current_art) = &current.cover_art {\n                    if current_art.url == *art_url {\n                        return Ok(current_art.clone());\n                    }\n                }\n            }\n\n            // Handle file:// scheme\n            if art_url.starts_with(\"file://\") {\n                let art_path =  Url::from_str(&art_url)\n                    .map_err(|err| format!(\"Failed to parse url: {art_url}: {err}\"))?\n                    .to_file_path()\n                    .map_err(|_| format!(\"Failed to convert url: {art_url} to file_path\"))?;\n\n                let bytes = fs::read(&art_path)\n                    .map_err(|err| format!(\"Failed to read art file: {err}\"))?;\n\n                let cover_art = ImageReader::new(Cursor::new(bytes))\n                    .with_guessed_format()\n                    .map_err(|_| \"Unknown image file_type\".to_string())?\n                    .decode()\n                    .map_err(|_| \"Failed to decode image\".to_string())?;\n\n                return Ok(CoverArt {\n                    url: art_url.to_string(),\n                    image: picker.new_resize_protocol(cover_art)\n                })\n            }\n\n            // Handle base64\n            if art_url.starts_with(\"data:\") {\n                let base64_data = art_url\n                    .split_once(\"base64,\")\n                    .ok_or(\"Invalid base64 url format\")?\n                .1;\n\n                let bytes = BASE64_STANDARD.decode(base64_data)\n                    .map_err(|err| format!(\"Failed to decode base64 data: {err}\"))?;\n\n                let cover_art = ImageReader::new(Cursor::new(bytes))\n                    .with_guessed_format()\n                    .map_err(|_| \"Unknown image file_type\".to_string())?\n                    .decode()\n                    .map_err(|_| \"Failed to decode image\".to_string())?;\n\n                return Ok(CoverArt {\n                    url: art_url.to_string(),\n                    image: picker.new_resize_protocol(cover_art),\n                });\n            }\n\n            let client = reqwest::blocking::Client::new();\n            let resp = client\n                .get(art_url)\n                .header(RANGE, \"bytes=0-1048576\")\n                .send()\n                .map_err(|_| \"Failed to fetch art url\".to_string())?;\n\n            let bytes = resp.bytes()\n                .map_err(|_| \"Failed to get art image bytes\".to_string())?;\n\n            let cover_art = ImageReader::new(Cursor::new(bytes))\n                .with_guessed_format()\n                .map_err(|_| \"Unknown image file_type\".to_string())?\n                .decode()\n                .map_err(|_| \"Failed to decode image\".to_string())?;\n\n            return Ok(CoverArt {\n                url: art_url.to_string(),\n                image: picker.new_resize_protocol(cover_art)\n            })\n        }\n\n        Err(Box::new(\n            io::Error::new(\n                io::ErrorKind::Other,\n                \"mpris:artUrl is not a string.\"\n            )\n        ))\n    }\n}\n"
  },
  {
    "path": "src/regexes.rs",
    "content": "use lazy_static::lazy_static;\nuse regex::Regex;\n\nlazy_static! {\n    pub static ref JSONC_COMMENT_RE: Regex = Regex::new(r#\"(?m)//.*$|/\\*[\\s\\S]*?\\*/\"#).unwrap();\n\n    pub static ref FORWARD_RE: Regex = Regex::new(r\"forward\\((-?\\d+)\\)\").unwrap();\n    pub static ref BACKWARD_RE: Regex = Regex::new(r\"backward\\((-?\\d+)\\)\").unwrap();\n    pub static ref POSITION_RE: Regex = Regex::new(r\"^position\\(\\d+\\)$\").unwrap();\n\n    pub static ref VOLUME_RE: Regex = Regex::new(r\"volume\\(([-+]?\\d+)\\)\").unwrap();\n\n    pub static ref VAR_RE: Regex = Regex::new(r\"var\\((\\$\\w[\\w-]*),\\s*(\\$\\w[\\w-]*)\\)\").unwrap();\n    pub static ref VAR_TOGGLE_RE: Regex = Regex::new(r\"toggle\\((\\$\\w[-\\w]*),\\s*(\\$\\w[-\\w]*),\\s*(\\$\\w[-\\w]*)\\)\").unwrap();\n    pub static ref VAR_SET_RE: Regex = Regex::new(r\"set\\((\\$\\w[-\\w]*),\\s*(\\$\\w[-\\w]*)\\)\").unwrap();\n\n    pub static ref GET_META_RE: Regex = Regex::new(r\"get_meta\\((.*?)\\)\").unwrap();\n\n    pub static ref LOWER_RE: Regex = Regex::new(r\"lower\\(\\s*([\\w$-]+(?:\\s+[\\w$-]+)*)\\s*\\)\").unwrap();\n    pub static ref UPPER_RE: Regex = Regex::new(r\"upper\\(\\s*([\\w$-]+(?:\\s+[\\w$-]+)*)\\s*\\)\").unwrap();\n}\n"
  },
  {
    "path": "src/state.rs",
    "content": "use std::collections::HashMap;\n\nuse ratatui::{layout::Rect, style::Color};\n\nuse crate::{action::Action, meta::Meta, widget::{Direction, SliderSource}};\n\npub struct FumState {\n    pub meta: Meta,\n    pub cover_art_ascii: String,\n    pub buttons: HashMap<String, (Rect, Option<Action>, Option<Action>, Option<String>)>,\n    pub sliders: HashMap<String, (Rect, Direction, SliderSource)>,\n    pub vars: HashMap<String, String>,\n    pub parent_direction: Direction,\n    pub parent_bg: Color,\n    pub parent_fg: Color\n}\n\nimpl FumState {\n    pub fn new(meta: Meta) -> Self {\n        Self {\n            meta,\n            cover_art_ascii: String::new(),\n            buttons: HashMap::new(),\n            sliders: HashMap::new(),\n            vars: HashMap::new(),\n            parent_direction: Direction::default(),\n            parent_bg: Color::Reset,\n            parent_fg: Color::Reset\n        }\n    }\n}\n"
  },
  {
    "path": "src/text.rs",
    "content": "use regex::Captures;\n\nuse crate::{meta::Meta, regexes::{GET_META_RE, LOWER_RE, UPPER_RE, VAR_RE}, state::FumState, utils::widget::{format_duration, format_remaining}};\n\nfn replace_global_var(text: &str, state: &mut FumState) -> String {\n    match text {\n        text if text.contains(\"$title\")                 => text.replace(\"$title\", &state.meta.title),\n        text if text.contains(\"$artists\")               => text.replace(\"$artists\", &state.meta.artists.join(\", \")),\n        text if text.contains(\"$album\")                 => text.replace(\"$album\", &state.meta.album),\n\n        text if text.contains(\"$status-icon\")           => text.replace(\"$status-icon\", &state.meta.status_icon.to_string()),\n        text if text.contains(\"$status-text\")           => text.replace(\"$status-text\", &state.meta.status_text),\n\n        text if text.contains(\"$position-ext\")          => text.replace(\"$position-ext\", &format_duration(state.meta.position, true)),\n        text if text.contains(\"$position\")              => text.replace(\"$position\", &format_duration(state.meta.position, false)),\n\n        text if text.contains(\"$remaining-length-ext\")  => text.replace(\"$remaining-length-ext\", &format_remaining(state.meta.position, state.meta.length, true)),\n        text if text.contains(\"$remaining-length\")      => text.replace(\"$remaining-length\", &format_remaining(state.meta.position, state.meta.length, false)),\n\n        text if text.contains(\"$length-ext\")            => text.replace(\"$length-ext\", &format_duration(state.meta.length, true)),\n        text if text.contains(\"$length\")                => text.replace(\"$length\", &format_duration(state.meta.length, false)),\n\n        text if text.contains(\"$volume\")                => text.replace(\"$volume\", &format!(\"{:.0}\", state.meta.volume * 100.0)),\n        _                                               => text.to_string()\n    }\n}\n\npub fn replace_text(text: &str, state: &mut FumState) -> String {\n    match text {\n        // get_meta() text action\n        text if GET_META_RE.is_match(text) => {\n            GET_META_RE.replace_all(text, |c: &Captures| {\n                let key = c[1].to_string();\n                Meta::get_custom_meta(&state.meta.metadata, key)\n            }).to_string()\n        },\n\n        // var() text action\n        text if VAR_RE.is_match(text) => {\n            VAR_RE.replace_all(text, |c: &Captures| {\n                let mut vars = state.vars.clone();\n\n                let name = c[1].to_string();\n                let default_text = c[2].to_string();\n\n                match vars.get(&name) {\n                    Some(var) => return replace_text(var, state),\n                    None => {\n                        vars.insert(name, default_text.to_string());\n\n                        // Update state.vars\n                        state.vars = vars;\n\n                        return replace_text(&default_text, state);\n                    }\n                }\n            }).to_string()\n        },\n\n        // lower() text action\n        text if LOWER_RE.is_match(text) => {\n            LOWER_RE.replace_all(text, |c: &Captures| {\n                let value = c[1].to_string();\n\n                if value.starts_with(\"$\") {\n                    replace_global_var(&value, state).to_lowercase()\n                } else {\n                    value.to_lowercase()\n                }\n            }).to_string()\n        },\n\n        // upper() text action\n        text if UPPER_RE.is_match(text) => {\n            UPPER_RE.replace_all(text, |c: &Captures| {\n                let value = c[1].to_string();\n\n                if value.starts_with(\"$\") {\n                    replace_global_var(&value, state).to_uppercase()\n                } else {\n                    value.to_uppercase()\n                }\n            }).to_string()\n        },\n\n        // global vars\n        text if text.contains(\"$\") => replace_global_var(text, state),\n\n        _ => text.to_string()\n    }\n}\n"
  },
  {
    "path": "src/ui.rs",
    "content": "use std::collections::HashMap;\n\nuse ratatui::{layout::{Constraint, Layout, Margin, Position, Rect}, style::Stylize, widgets::{Block, Borders, Paragraph, Wrap}, Frame};\n\nuse crate::{action::Action, config::Config, get_border, state::FumState, utils, widget::{Direction, SliderSource}};\n\npub struct Ui<'a> {\n    config: &'a Config,\n}\n\nimpl<'a> Ui<'a> {\n    pub fn new(config: &'a Config) -> Self {\n        Self {\n            config,\n        }\n    }\n\n    pub fn click(\n        &self,\n        x: u16,\n        y: u16,\n        buttons: &'a HashMap<String, (Rect, Option<Action>, Option<Action>, Option<String>)>\n    ) -> Option<(&'a Option<Action>, &'a Option<Action>, &'a Option<String>)> {\n        for (_, (rect, action, action_secondary, exec)) in buttons.iter() {\n            if rect.contains(Position::new(x, y)) {\n                return Some((\n                    action,\n                    action_secondary,\n                    exec\n                ))\n            }\n        }\n\n        None\n    }\n\n    pub fn drag(\n        &self,\n        start_drag: &Position,\n        sliders: &HashMap<String, (Rect, Direction, SliderSource)>\n    ) -> Option<(Rect, Direction, SliderSource)> {\n        for (_, (rect, direction, widget)) in sliders.iter() {\n            if rect.contains(*start_drag) {\n                return Some((*rect, direction.to_owned(), *widget));\n            }\n        }\n\n        None\n    }\n\n    pub fn draw(&mut self, frame: &mut Frame<'_>, state: &mut FumState) {\n        let main_area = utils::align::get_align(frame, &self.config.align, self.config.width, self.config.height);\n\n        // Terminal window is too small\n        if &frame.area().width < &self.config.width ||\n            &frame.area().height < &self.config.height {\n            frame.render_widget(\n                Paragraph::new(format!(\n                    \"Terminal window is too small. Must have atleast ({}x{}).\",\n                    &self.config.width, &self.config.height\n                ))\n                    .centered()\n                    .wrap(Wrap::default())\n                    .block(Block::new().borders(Borders::ALL)),\n                main_area\n            );\n\n            return;\n        }\n\n        // Sets the state parents state\n        state.parent_direction = self.config.direction.to_owned();\n        state.parent_bg = self.config.bg;\n        state.parent_fg = self.config.fg;\n\n        // Also pass in the cover_art_ascii on state\n        state.cover_art_ascii = self.config.cover_art_ascii.to_owned();\n\n        let areas = Layout::default()\n            .direction(self.config.direction.to_dir())\n            .flex(self.config.flex.to_flex())\n            .constraints(\n                self.config.layout\n                    .iter()\n                    .map(|child| child.get_size(state))\n                    .collect::<Vec<Constraint>>()\n            )\n            .split(main_area);\n\n        // Whether to render border\n        let border = get_border!(&self.config.border);\n\n        // Render background\n        frame.render_widget(\n            Block::new()\n                .borders(border)\n                .bg(state.parent_bg)\n                .fg(state.parent_fg),\n            main_area\n        );\n\n        for (i, widget) in self.config.layout.iter().enumerate() {\n            if let Some(area) = areas.get(i) {\n                let [horizontal_padding, vertical_padding] = &self.config.padding;\n                frame.render_stateful_widget(widget, area.inner(Margin::new(*horizontal_padding, *vertical_padding)), state);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/utils/align.rs",
    "content": "use ratatui::{layout::{Constraint, Flex, Layout, Rect}, Frame};\n\nuse crate::config::Align;\n\npub fn center(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area] = Layout::horizontal([Constraint::Length(width)])\n        .flex(Flex::Center)\n        .areas(frame.area());\n\n    let [area] = Layout::vertical([Constraint::Length(height)])\n        .flex(Flex::Center)\n        .areas(area);\n\n    area\n}\n\npub fn top(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area] = Layout::horizontal([Constraint::Length(width)])\n        .flex(Flex::Center)\n        .areas(frame.area());\n\n    let [area] = Layout::vertical([Constraint::Length(height)])\n        .areas(area);\n\n    area\n}\n\npub fn left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area] = Layout::vertical([Constraint::Length(height)])\n        .flex(Flex::Center)\n        .areas(frame.area());\n\n    let [area] = Layout::horizontal([Constraint::Length(width)])\n        .areas(area);\n\n    area\n}\n\npub fn bottom(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area] = Layout::horizontal([Constraint::Length(width)])\n        .flex(Flex::Center)\n        .areas(frame.area());\n\n    let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)])\n        .areas(area);\n\n    area\n}\n\npub fn right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area] = Layout::vertical([Constraint::Length(height)])\n        .flex(Flex::Center)\n        .areas(frame.area());\n\n    let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)])\n        .areas(area);\n\n    area\n}\n\npub fn top_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area, _] = Layout::horizontal([Constraint::Length(width), Constraint::Min(0)])\n        .areas(frame.area());\n\n    let [area, _] = Layout::vertical([Constraint::Length(height), Constraint::Min(0)])\n        .areas(area);\n\n    area\n}\n\npub fn top_right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)])\n        .areas(frame.area());\n\n    let [area, _] = Layout::vertical([Constraint::Length(height), Constraint::Min(0)])\n        .areas(area);\n\n    area\n}\n\npub fn bottom_left(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [area, _] = Layout::horizontal([Constraint::Length(width), Constraint::Min(0)])\n        .areas(frame.area());\n\n    let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)])\n        .areas(area);\n\n    area\n}\n\npub fn bottom_right(frame: &mut Frame<'_>, width: u16, height: u16) -> Rect {\n    let [_, area] = Layout::horizontal([Constraint::Min(0), Constraint::Length(width)])\n        .areas(frame.area());\n\n    let [_, area] = Layout::vertical([Constraint::Min(0), Constraint::Length(height)])\n        .areas(area);\n\n    area\n}\n\npub fn get_align(frame: &mut Frame<'_>, align: &Align, width: u16, height: u16) -> Rect {\n    match align {\n        Align::Center           => center(frame, width, height),\n        Align::Top              => top(frame, width, height),\n        Align::Left             => left(frame, width, height),\n        Align::Bottom           => bottom(frame, width, height),\n        Align::Right            => right(frame, width, height),\n        Align::TopLeft          => top_left(frame, width, height),\n        Align::TopRight         => top_right(frame, width, height),\n        Align::BottomLeft       => bottom_left(frame, width, height),\n        Align::BottomRight      => bottom_right(frame, width, height)\n    }\n}\n"
  },
  {
    "path": "src/utils/mod.rs",
    "content": "pub mod terminal;\npub mod align;\npub mod widget;\n"
  },
  {
    "path": "src/utils/terminal.rs",
    "content": "use std::io::stdout;\n\nuse crossterm::{event::DisableMouseCapture, execute};\n\npub fn restore() {\n    ratatui::restore();\n    execute!(stdout(), DisableMouseCapture)\n        .expect(\"Failed to disable mouse capture.\");\n}\n"
  },
  {
    "path": "src/utils/widget.rs",
    "content": "use std::time::Duration;\nuse uuid::Uuid;\n\n#[macro_export]\nmacro_rules! get_size {\n    ($orientation:expr, $size:expr, $area:expr) => {{\n        let [area] = match $size {\n            Some(width) => $orientation([Constraint::Length(*width)]).areas($area),\n            None => $orientation([Constraint::Min(0)]).areas($area),\n        };\n\n        area\n    }};\n}\n\n#[macro_export]\nmacro_rules! get_color {\n    ($bg:expr, $fg:expr, $parent_bg:expr, $parent_fg:expr) => {{\n        let bg = match $bg {\n            Some(bg) => bg,\n            None => $parent_bg,\n        };\n\n        let fg = match $fg {\n            Some(fg) => fg,\n            None => $parent_fg,\n        };\n\n        (bg, fg)\n    }};\n}\n\n#[macro_export]\nmacro_rules! get_bold {\n    ($bold:expr) => {{\n        match $bold {\n            true => ratatui::style::Modifier::BOLD,\n            false => ratatui::style::Modifier::default()\n        }\n    }};\n}\n\n#[macro_export]\nmacro_rules! get_border {\n    ($border:expr) => {{\n        match $border {\n            true => ratatui::widgets::Borders::ALL,\n            false => ratatui::widgets::Borders::NONE\n        }\n    }};\n}\n\npub fn generate_id() -> String {\n    Uuid::new_v4().to_string()\n}\n\npub fn truncate(string: &str, area_size: usize) -> String {\n    if string.chars().count() <= area_size {\n        string.to_string()\n    } else {\n        // minus 3 since the dots (...)\n        let take = if area_size > 3 { area_size - 3 } else { area_size };\n        let truncated: String = string.chars().take(take).collect();\n        format!(\"{}...\", truncated)\n    }\n}\n\npub fn format_duration(duration: Duration, extend: bool) -> String {\n    if duration.as_secs() >= 3600 {\n        if extend {\n            format!(\n                \"{:02}:{:02}:{:02}\",\n                duration.as_secs() / 3600,\n                (duration.as_secs() % 3600) / 60,\n                duration.as_secs() % 60\n            )\n        } else {\n            format!(\n                \"{}:{:02}:{:02}\",\n                duration.as_secs() / 3600,\n                (duration.as_secs() % 3600) / 60,\n                duration.as_secs() % 60\n            )\n        }\n    } else {\n        if extend {\n            format!(\"{:02}:{:02}\", duration.as_secs() / 60, duration.as_secs() % 60)\n        } else {\n            format!(\"{}:{:02}\", duration.as_secs() / 60, duration.as_secs() % 60)\n        }\n    }\n}\n\npub fn format_remaining(current: Duration, total: Duration, extend: bool) -> String {\n    if total > current {\n        let remaining = total - current;\n        format!(\"-{}\", format_duration(remaining, extend))\n    } else {\n        format!(\"-0:00\")\n    }\n}\n\n"
  },
  {
    "path": "src/widget/button.rs",
    "content": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get_bold, get_color, state::FumState, text::replace_text};\n\nuse super::FumWidget;\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Button { id, text, action, action_secondary, exec, bold, bg, fg, .. } = widget {\n        let text = replace_text(text, state).to_string();\n\n        state.buttons.insert(\n            id.to_string(),\n            (\n                area.clone(),\n                action.to_owned(),\n                action_secondary.to_owned(),\n                exec.to_owned()\n            )\n        );\n\n        let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg);\n        let bold = get_bold!(bold);\n\n        // Render bg\n        Block::new()\n            .bg(*bg)\n            .render(area, buf);\n\n        Paragraph::new(text)\n            .wrap(Wrap::default())\n            .add_modifier(bold)\n            .fg(*fg)\n            .render(area, buf);\n    }\n}\n"
  },
  {
    "path": "src/widget/container.rs",
    "content": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Margin, Rect}, style::Stylize, widgets::{Block, StatefulWidget, Widget}};\n\nuse crate::{get_border, get_color, state::FumState};\n\nuse super::FumWidget;\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Container { width, height, direction, border, padding, children, flex, bg, fg } = widget {\n        let area = Rect::new(\n            area.x,\n            area.y,\n            width.unwrap_or(area.width),\n            height.unwrap_or(area.height)\n        );\n\n        let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg);\n        let border = get_border!(border);\n\n        Block::new()\n            .borders(border)\n            .bg(*bg)\n            .fg(*fg)\n            .render(area, buf);\n\n        // Sets the state parents state\n        state.parent_direction = direction.to_owned();\n        state.parent_bg = *bg;\n        state.parent_fg = *fg;\n\n        let areas = Layout::default()\n            .direction(direction.to_dir())\n            .flex(flex.to_flex())\n            .constraints(\n                children\n                    .iter()\n                    .map(|child| child.get_size(state))\n                    .collect::<Vec<Constraint>>()\n            )\n            .split(area);\n\n        for (i, child) in children.iter().enumerate() {\n            if let Some(area) = areas.get(i) {\n                let [horizontal_padding, vertical_padding] = padding;\n                child.render(area.inner(Margin::new(*horizontal_padding, *vertical_padding)), buf, state);\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/widget/cover_art.rs",
    "content": "use ratatui::{buffer::Buffer, layout::{Constraint, Flex, Layout, Rect}, style::Stylize, text::Text, widgets::{Block, StatefulWidget, Widget}};\nuse ratatui_image::StatefulImage;\n\nuse crate::{get_border, get_color, state::FumState};\n\nuse super::FumWidget;\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::CoverArt { resize, border, bg, fg, .. } = widget {\n        let (bg, _) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg);\n\n        let border = get_border!(border);\n\n        // Render bg\n        Block::new()\n            .borders(border)\n            .bg(*bg)\n            .render(area, buf);\n\n        if let Some(cover_art) = state.meta.cover_art.as_mut() {\n            StatefulWidget::render(\n                StatefulImage::default().resize(resize.to_resize()),\n                area,\n                buf,\n                &mut cover_art.image\n            );\n        } else {\n            let split = state.cover_art_ascii.split('\\n');\n            let width = split.to_owned().map(|line| line.len() as u16).max().unwrap_or(0);\n            let height = split.count() as u16;\n\n            let [ascii_area] = Layout::horizontal([Constraint::Length(width)]).flex(Flex::Center).areas(area);\n            let [ascii_area] = Layout::vertical([Constraint::Length(height)]).flex(Flex::Center).areas(ascii_area);\n\n            Text::from(state.cover_art_ascii.as_str())\n                .render(ascii_area, buf);\n        }\n    }\n}\n"
  },
  {
    "path": "src/widget/empty.rs",
    "content": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Widget}};\n\nuse crate::{get_color, state::FumState};\n\nuse super::FumWidget;\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Empty { bg, fg, .. } = widget {\n        let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg);\n\n        Block::new()\n            .bg(*bg)\n            .fg(*fg)\n            .render(area, buf);\n    }\n}\n"
  },
  {
    "path": "src/widget/label.rs",
    "content": "use ratatui::{buffer::Buffer, layout::Rect, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get_bold, get_color, state::FumState, text::replace_text, utils};\n\nuse super::{Direction, FumWidget, LabelAlignment};\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Label { text, direction, truncate, align, bold, bg, fg } = widget {\n        let text = match (truncate, direction) {\n            (true, Direction::Horizontal) => utils::widget::truncate(&replace_text(text, state), area.width.into()),\n            (true, Direction::Vertical) => utils::widget::truncate(&replace_text(text, state), area.height.into()),\n            _ => replace_text(text, state)\n        };\n\n        let (bg, fg) = get_color!(bg, fg, &state.parent_bg, &state.parent_fg);\n\n        // Whether the text is bold\n        let bold = get_bold!(bold);\n\n        let widget = match align {\n            LabelAlignment::Left => Paragraph::new(text).left_aligned().wrap(Wrap::default()).fg(*fg).add_modifier(bold),\n            LabelAlignment::Center => Paragraph::new(text).centered().wrap(Wrap::default()).fg(*fg).add_modifier(bold),\n            LabelAlignment::Right => Paragraph::new(text).right_aligned().wrap(Wrap::default()).fg(*fg).add_modifier(bold),\n        };\n\n        // Render bg\n        Block::new()\n            .bg(*bg)\n            .render(area, buf);\n\n        widget.render(area, buf);\n    }\n}\n"
  },
  {
    "path": "src/widget/mod.rs",
    "content": "mod widget;\nmod container;\nmod cover_art;\nmod label;\nmod button;\nmod progress;\nmod volume;\nmod empty;\n\npub use widget::*;\n"
  },
  {
    "path": "src/widget/progress.rs",
    "content": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get_color, state::FumState};\n\nuse super::{Direction, FumWidget, SliderSource};\n\nstruct Progress {\n    progress_bar: String,\n    empty_bar: String,\n    progress_area: Rect,\n    empty_area: Rect\n}\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Progress { id, direction, progress: prog_opt, empty: empt_opt, .. } = widget {\n        let (prog_bg, prog_fg) = get_color!(&prog_opt.bg, &prog_opt.fg, &state.parent_bg, &state.parent_fg);\n        let (empt_bg, empt_fg) = get_color!(&empt_opt.bg, &empt_opt.fg, &state.parent_bg, &state.parent_fg);\n\n        let progress_char = prog_opt.char.to_string();\n        let empty_char = empt_opt.char.to_string();\n\n        state.sliders.insert(\n            id.to_string(),\n            (area.clone(), direction.clone(), SliderSource::Progress)\n        );\n\n        let position = state.meta.position;\n        let ratio = if position.as_secs() > 0 {\n            position.as_secs() as f64 / state.meta.length.as_secs() as f64\n        } else {\n            0.0\n        };\n\n        let Progress {\n            progress_bar,\n            empty_bar,\n            progress_area,\n            empty_area\n        } = match direction {\n            Direction::Horizontal => {\n                let filled = (ratio * area.width as f64).round();\n                let empty = area.width.saturating_sub(filled as u16);\n\n                let progress_bar = progress_char.repeat(filled as usize);\n                let empty_bar = empty_char.repeat(empty as usize);\n\n                let [progress_area, empty_area] = Layout::horizontal([\n                    Constraint::Length(filled as u16),\n                    Constraint::Length(empty as u16)\n                ]).areas(area);\n\n                Progress {\n                    progress_bar,\n                    empty_bar,\n                    progress_area,\n                    empty_area\n                }\n            },\n            Direction::Vertical => {\n                let filled = (ratio * area.height as f64).round();\n                let empty = area.height.saturating_sub(filled as u16);\n\n                let progress_bar = progress_char.repeat(filled as usize);\n                let empty_bar = empty_char.repeat(empty as usize);\n\n                let [empty_area, progress_area] = Layout::vertical([\n                    Constraint::Length(empty as u16),\n                    Constraint::Length(filled as u16)\n                ]).areas(area);\n\n                Progress {\n                    progress_bar,\n                    empty_bar,\n                    progress_area,\n                    empty_area\n                }\n            }\n        };\n\n        // Render progress bg\n        Block::new()\n            .bg(*prog_bg)\n            .render(progress_area, buf);\n\n        // Render progress\n        Paragraph::new(progress_bar)\n            .wrap(Wrap::default())\n            .fg(*prog_fg)\n            .render(progress_area, buf);\n\n        // Render empty bg\n        Block::new()\n            .bg(*empt_bg)\n            .render(empty_area, buf);\n\n        // Render empty\n        Paragraph::new(empty_bar)\n            .wrap(Wrap::default())\n            .fg(*empt_fg)\n            .render(empty_area, buf);\n    }\n}\n"
  },
  {
    "path": "src/widget/volume.rs",
    "content": "use ratatui::{buffer::Buffer, layout::{Constraint, Layout, Rect}, style::Stylize, widgets::{Block, Paragraph, Widget, Wrap}};\n\nuse crate::{get_color, state::FumState};\n\nuse super::{Direction, FumWidget, SliderSource};\n\nstruct Volume {\n    volume_bar: String,\n    empty_bar: String,\n    volume_area: Rect,\n    empty_area: Rect\n}\n\npub fn render(widget: &FumWidget, area: Rect, buf: &mut Buffer, state: &mut FumState) {\n    if let FumWidget::Volume { id, direction, volume: vol_opt, empty: empt_opt, .. } = widget {\n        let (vol_bg, vol_fg) = get_color!(&vol_opt.bg, &vol_opt.fg, &state.parent_bg, &state.parent_fg);\n        let (empt_bg, empt_fg) = get_color!(&empt_opt.bg, &empt_opt.fg, &state.parent_bg, &state.parent_fg);\n\n        let progress_char = vol_opt.char.to_string();\n        let empty_char = empt_opt.char.to_string();\n\n        state.sliders.insert(\n            id.to_string(),\n            (area.clone(), direction.clone(), SliderSource::Volume)\n        );\n\n        let Volume {\n            volume_bar,\n            empty_bar,\n            volume_area,\n            empty_area\n        } = match direction {\n            Direction::Horizontal => {\n                let filled = (state.meta.volume * area.width as f64).round();\n                let empty = area.width.saturating_sub(filled as u16);\n\n                let volume_bar = progress_char.repeat(filled as usize);\n                let empty_bar = empty_char.repeat(empty as usize);\n\n                let [volume_area, empty_area] = Layout::horizontal([\n                    Constraint::Length(filled as u16),\n                    Constraint::Length(empty as u16)\n                ]).areas(area);\n\n                Volume {\n                    volume_bar,\n                    empty_bar,\n                    volume_area,\n                    empty_area\n                }\n            },\n            Direction::Vertical => {\n                let filled = (state.meta.volume * area.height as f64).round();\n                let empty = area.height.saturating_sub(filled as u16);\n\n                let volume_bar = progress_char.repeat(filled as usize);\n                let empty_bar = empty_char.repeat(empty as usize);\n\n                let [empty_area, volume_area] = Layout::vertical([\n                    Constraint::Length(empty as u16),\n                    Constraint::Length(filled as u16)\n                ]).areas(area);\n\n                Volume {\n                    volume_bar,\n                    empty_bar,\n                    volume_area,\n                    empty_area\n                }\n            }\n        };\n\n        // Render volume filled bg\n        Block::new()\n            .bg(*vol_bg)\n            .render(volume_area, buf);\n\n        // Render volume filled\n        Paragraph::new(volume_bar)\n            .wrap(Wrap::default())\n            .fg(*vol_fg)\n            .render(volume_area, buf);\n\n        // Render empty bg\n        Block::new()\n            .bg(*empt_bg)\n            .render(empty_area, buf);\n\n        // Render empty\n        Paragraph::new(empty_bar)\n            .wrap(Wrap::default())\n            .fg(*empt_fg)\n            .render(empty_area, buf);\n    }\n}\n"
  },
  {
    "path": "src/widget/widget.rs",
    "content": "use ratatui::{buffer::Buffer, layout::{Constraint, Rect}, style::Color, widgets::StatefulWidget};\nuse serde::Deserialize;\nuse unicode_width::UnicodeWidthStr;\nuse crate::{action::Action, state::FumState, text::replace_text, utils::widget::generate_id};\n\nuse super::{button, container, cover_art, empty, label, progress, volume};\n\nfn default_truncate() -> bool { true }\nfn default_border() -> bool { false }\nfn default_bold() -> bool { false }\nfn default_padding() -> [u16; 2] { [0, 0] }\n\n#[derive(Debug, Copy, Clone)]\npub enum SliderSource {\n    Progress,\n    Volume\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum Direction {\n    Vertical,\n    Horizontal\n}\n\nimpl Default for Direction {\n    fn default() -> Self {\n        Self::Horizontal\n    }\n}\n\nimpl Direction {\n    pub fn to_dir(&self) -> ratatui::layout::Direction {\n        match self {\n            Self::Horizontal => ratatui::layout::Direction::Horizontal,\n            Self::Vertical => ratatui::layout::Direction::Vertical\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum LabelAlignment {\n    Left,\n    Center,\n    Right\n}\n\nimpl Default for LabelAlignment {\n    fn default() -> Self {\n        Self::Left\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum ContainerFlex {\n    Start,\n    Center,\n    End,\n    #[serde(rename = \"space-around\")]\n    SpaceAround,\n    #[serde(rename = \"space-between\")]\n    SpaceBetween\n}\n\nimpl Default for ContainerFlex {\n    fn default() -> Self {\n        ContainerFlex::Start\n    }\n}\n\nimpl ContainerFlex {\n    pub fn to_flex(&self) -> ratatui::layout::Flex {\n        match self {\n            Self::Start         => ratatui::layout::Flex::Start,\n            Self::Center        => ratatui::layout::Flex::Center,\n            Self::End           => ratatui::layout::Flex::End,\n            Self::SpaceAround   => ratatui::layout::Flex::SpaceAround,\n            Self::SpaceBetween  => ratatui::layout::Flex::SpaceBetween\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(rename_all = \"lowercase\")]\npub enum CoverArtResize {\n    Fit,\n    Crop,\n    Scale\n}\n\nimpl Default for CoverArtResize {\n    fn default() -> Self {\n        Self::Scale\n    }\n}\n\nimpl CoverArtResize {\n    pub fn to_resize(&self) -> ratatui_image::Resize {\n        match self {\n            Self::Fit       => ratatui_image::Resize::Fit(Some(ratatui_image::FilterType::CatmullRom)),\n            Self::Crop      => ratatui_image::Resize::Crop(None),\n            Self::Scale     => ratatui_image::Resize::Scale(Some(ratatui_image::FilterType::CatmullRom))\n        }\n    }\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct ProgressOption {\n    pub char: char,\n    pub bg: Option<Color>,\n    pub fg: Option<Color>\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct VolumeOption {\n    pub char: char,\n    pub bg: Option<Color>,\n    pub fg: Option<Color>\n}\n\n#[derive(Debug, Clone, Deserialize)]\n#[serde(tag = \"type\")]\n#[serde(rename_all = \"lowercase\")]\npub enum FumWidget {\n    Container {\n        width: Option<u16>,\n        height: Option<u16>,\n        #[serde(default = \"Direction::default\")]\n        direction: Direction,\n        #[serde(default = \"default_border\")]\n        border: bool,\n        #[serde(default = \"default_padding\")]\n        padding: [u16; 2],\n        children: Vec<FumWidget>,\n        #[serde(default = \"ContainerFlex::default\")]\n        flex: ContainerFlex,\n        bg: Option<Color>,\n        fg: Option<Color>\n    },\n\n    #[serde(rename = \"cover-art\")]\n    CoverArt {\n        width: Option<u16>,\n        height: Option<u16>,\n        #[serde(default = \"CoverArtResize::default\")]\n        resize: CoverArtResize,\n        #[serde(default = \"default_border\")]\n        border: bool,\n        bg: Option<Color>,\n        fg: Option<Color>\n    },\n\n    Label {\n        text: String,\n        #[serde(default = \"Direction::default\")]\n        direction: Direction,\n        #[serde(default = \"LabelAlignment::default\")]\n        align: LabelAlignment,\n        #[serde(default = \"default_truncate\")]\n        truncate: bool,\n        #[serde(default = \"default_bold\")]\n        bold: bool,\n        bg: Option<Color>,\n        fg: Option<Color>\n    },\n\n    Button {\n        #[serde(default = \"generate_id\")]\n        id: String,\n        text: String,\n        action: Option<Action>,\n        #[serde(rename = \"action-secondary\")]\n        action_secondary: Option<Action>,\n        exec: Option<String>,\n        #[serde(default = \"Direction::default\")]\n        direction: Direction,\n        #[serde(default = \"default_bold\")]\n        bold: bool,\n        bg: Option<Color>,\n        fg: Option<Color>\n    },\n\n    Progress {\n        #[serde(default = \"generate_id\")]\n        id: String,\n        size: Option<u16>,\n        #[serde(default = \"Direction::default\")]\n        direction: Direction,\n        progress: ProgressOption,\n        empty: ProgressOption\n    },\n\n    Volume {\n        #[serde(default = \"generate_id\")]\n        id: String,\n        size: Option<u16>,\n        #[serde(default = \"Direction::default\")]\n        direction: Direction,\n        volume: VolumeOption,\n        empty: VolumeOption\n    },\n\n    Empty {\n        size: u16,\n        bg: Option<Color>,\n        fg: Option<Color>\n    }\n}\n\nimpl StatefulWidget for &FumWidget {\n    type State = FumState;\n\n    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State)\n    where\n        Self: Sized\n    {\n        match self {\n            FumWidget::Container { .. } => container::render(&self, area, buf, state),\n            FumWidget::CoverArt { .. } => cover_art::render(&self, area, buf, state),\n            FumWidget::Label { .. } => label::render(&self, area, buf, state),\n            FumWidget::Button { .. } => button::render(&self, area, buf, state),\n            FumWidget::Progress { .. } => progress::render(&self, area, buf, state),\n            FumWidget::Volume { .. } => volume::render(&self, area, buf, state),\n            FumWidget::Empty { .. } => empty::render(&self, area, buf, state)\n        }\n    }\n}\n\nimpl FumWidget {\n    pub fn get_size(&self, state: &mut FumState) -> Constraint {\n        match self {\n            Self::Container { width, height, direction, .. } => {\n                match direction {\n                    Direction::Horizontal => width.map(|w| Constraint::Length(w)).unwrap_or(Constraint::Min(0)),\n                    Direction::Vertical => height.map(|h| Constraint::Length(h)).unwrap_or(Constraint::Min(0))\n                }\n            },\n            Self::CoverArt { width, height, .. } => {\n                match &state.parent_direction {\n                    Direction::Horizontal => width.map(|w| Constraint::Length(w)).unwrap_or(Constraint::Min(0)),\n                    Direction::Vertical => height.map(|h| Constraint::Length(h)).unwrap_or(Constraint::Min(0))\n                }\n            },\n            Self::Label { direction, .. } => {\n                match direction {\n                    Direction::Horizontal => Constraint::Min(0),\n                    Direction::Vertical => Constraint::Length(1)\n                }\n            },\n            Self::Button { direction, text, .. } => {\n                match direction {\n                    Direction::Horizontal => Constraint::Length(UnicodeWidthStr::width(replace_text(text, state).as_str()) as u16),\n                    Direction::Vertical => Constraint::Length(1)\n                }\n            },\n            Self::Progress { size, direction, .. } => {\n                match direction {\n                    Direction::Horizontal => size.map(|s| Constraint::Length(s)).unwrap_or(Constraint::Min(0)),\n                    Direction::Vertical => Constraint::Length(1)\n                }\n            },\n            Self::Volume { size, direction, .. } => {\n                match direction {\n                    Direction::Horizontal => size.map(|s| Constraint::Length(s)).unwrap_or(Constraint::Min(0)),\n                    Direction::Vertical => Constraint::Length(1)\n                }\n            },\n            Self::Empty { size, .. } => Constraint::Length(*size)\n        }\n    }\n}\n"
  }
]