[
  {
    "path": ".github/workflows/cd.yaml",
    "content": "name: CD\non:\n  push:\n    tags:\n      - \"v*\"\n\nenv:\n  RELEASE_BIN: pipes-rs\n\njobs:\n  create_release:\n    name: Create release\n    runs-on: ubuntu-latest\n    outputs:\n      upload_url: ${{ steps.step.outputs.upload_url }}\n\n    steps:\n      - uses: softprops/action-gh-release@v1\n        id: step\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n\n  build_release:\n    name: Build release\n    strategy:\n      matrix:\n        include:\n          - os: ubuntu-latest\n            targets: [x86_64-unknown-linux-gnu]\n            suffix: linux-x86_64.tar.gz\n          - os: macos-latest\n            targets: [x86_64-apple-darwin, aarch64-apple-darwin]\n            suffix: mac-universal.tar.gz\n          - os: windows-latest\n            targets: [x86_64-pc-windows-msvc]\n            suffix: windows-x86_64.zip\n    runs-on: ${{ matrix.os }}\n    needs: create_release\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust toolchain\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          targets: ${{ join(matrix.targets, ',') }}\n\n      - name: Build\n        shell: bash\n        run: |\n          for target in ${{ join(matrix.targets, ' ') }}; do\n            cargo build --release --target $target\n          done\n\n      - name: Create Linux archive\n        run: tar -czvf ./${{ env.RELEASE_BIN }}-linux-x86_64.tar.gz ./target/x86_64-unknown-linux-gnu/release/${{ env.RELEASE_BIN }}\n        if: matrix.os == 'ubuntu-latest'\n\n      - name: Create Windows archive\n        run: 7z a -tzip ./${{ env.RELEASE_BIN }}-windows-x86_64.zip ./target/x86_64-pc-windows-msvc/release/${{ env.RELEASE_BIN }}.exe\n        if: matrix.os == 'windows-latest'\n\n      - name: Create macOS archive\n        run: |\n          lipo -create -output ./${{ env.RELEASE_BIN }}-mac-universal ./target/x86_64-apple-darwin/release/${{ env.RELEASE_BIN }} ./target/aarch64-apple-darwin/release/${{ env.RELEASE_BIN }}\n          codesign --sign - --options runtime ./${{ env.RELEASE_BIN }}-mac-universal\n          tar -czvf ./${{ env.RELEASE_BIN }}-mac-universal.tar.gz ./${{ env.RELEASE_BIN }}-mac-universal\n        if: matrix.os == 'macos-latest'\n\n      - name: Upload archive\n        uses: shogo82148/actions-upload-release-asset@v1\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n        with:\n          upload_url: ${{ needs.create_release.outputs.upload_url }}\n          asset_path: ./${{ env.RELEASE_BIN }}-${{ matrix.suffix }}\n          asset_name: ${{ env.RELEASE_BIN }}-${{ matrix.suffix }}\n\n      - name: Get version\n        id: get-version\n        run: echo ::set-output name=version::${GITHUB_REF/refs\\/tags\\//}\n        if: matrix.os == 'macos-latest'\n\n      - name: Bump Homebrew formula\n        uses: mislav/bump-homebrew-formula-action@v3\n        with:\n          formula-name: pipes-rs\n          homebrew-tap: lhvy/homebrew-tap\n          download-url: https://github.com/lhvy/pipes-rs/releases/download/${{ steps.get-version.outputs.version }}/pipes-rs-mac-universal.tar.gz\n        env:\n          COMMITTER_TOKEN: ${{ secrets.BREW_TOKEN }}\n        if: matrix.os == 'macos-latest'\n"
  },
  {
    "path": ".github/workflows/ci.yaml",
    "content": "name: CI\n\non:\n  pull_request:\n  push:\n    branches: master\n    paths:\n      - \"**.rs\"\n      - \"**.toml\"\n      - \"**.lock\"\n      - \"**.yaml\"\n\nenv:\n  RUSTFLAGS: \"--deny warnings --warn unreachable-pub\"\n\njobs:\n  rust:\n    name: Rust\n\n    strategy:\n      matrix:\n        os: [ubuntu-latest, windows-latest, macos-latest]\n\n    runs-on: ${{ matrix.os }}\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          components: clippy\n\n      - name: Load Rust/Cargo cache\n        uses: Swatinem/rust-cache@v2\n\n      - name: Build\n        run: cargo build --all-targets --all-features --locked\n\n      - name: Clippy\n        run: cargo clippy --all-targets --all-features\n\n      - name: Test\n        run: cargo test --all-targets --all-features --locked\n\n  fmt:\n    name: Formatting\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Install Rust\n        uses: dtolnay/rust-toolchain@stable\n        with:\n          components: rustfmt\n\n      - name: Check formatting\n        run: cargo fmt -- --check\n"
  },
  {
    "path": ".gitignore",
    "content": "/target\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[workspace]\nmembers = [\"crates/*\"]\nresolver = \"2\"\n\n[profile.dev]\npanic = \"abort\"\n\n[profile.release]\npanic = \"abort\"\ncodegen-units = 1\nlto = \"fat\"\nstrip = true\n"
  },
  {
    "path": "LICENSE.md",
    "content": "# Blue Oak Model License\n\nVersion 1.0.0\n\n## Purpose\n\nThis license gives everyone as much permission to work with\nthis software as possible, while protecting contributors\nfrom liability.\n\n## Acceptance\n\nIn order to receive this license, you must agree to its\nrules. The rules of this license are both obligations\nunder that agreement and conditions to your license.\nYou must not do anything with this software that triggers\na rule that you cannot or will not follow.\n\n## Copyright\n\nEach contributor licenses you to do everything with this\nsoftware that would otherwise infringe that contributor's\ncopyright in it.\n\n## Notices\n\nYou must ensure that everyone who gets a copy of\nany part of this software from you, with or without\nchanges, also gets the text of this license or a link to\n<https://blueoakcouncil.org/license/1.0.0>.\n\n## Excuse\n\nIf anyone notifies you in writing that you have not\ncomplied with [Notices](#notices), you can keep your\nlicense by taking all practical steps to comply within 30\ndays after the notice. If you do not do so, your license\nends immediately.\n\n## Patent\n\nEach contributor licenses you to do everything with this\nsoftware that would otherwise infringe any patent claims\nthey can license or become able to license.\n\n## Reliability\n\nNo contributor can revoke this license.\n\n## No Liability\n\n**_As far as the law allows, this software comes as is,\nwithout any warranty or condition, and no contributor\nwill be liable to anyone for any damages related to this\nsoftware or this license, under any kind of legal claim._**\n"
  },
  {
    "path": "README.md",
    "content": "# pipes-rs\n\n![GitHub Actions CI status](https://github.com/lhvy/pipes-rs/actions/workflows/ci.yaml/badge.svg)\n\n> An over-engineered rewrite of pipes.sh in Rust\n\n![pipes-rs preview](https://github.com/lhvy/i/raw/master/pipes-rs-preview.gif)\n\n## Installation\n\nInstall the latest version release via git on any platform using Cargo:\n\n```sh\ncargo install --git https://github.com/lhvy/pipes-rs\n```\n\nAlternatively for macOS, install via Homebrew:\n\n```sh\nbrew install lhvy/tap/pipes-rs\n```\n\nAlternatively for Windows, install via Scoop:\n\n```sh\nscoop bucket add extras\nscoop install pipes-rs\n```\n\n### Manual Download\n\nDownload compiled binaries from [releases](https://github.com/lhvy/pipes-rs/releases/latest).\n\n## Windows Font Issues\n\nThere have been reports that some characters pipes-rs uses are missing on Windows, which causes them to appear as [tofu](https://en.wikipedia.org/wiki/Noto_fonts#Etymology). If you experience this issue, try using a font with a large character set, such as [Noto Mono](https://www.google.com/get/noto/).\n\n## Keybindings\n\n- <kbd>r</kbd>: reset the screen\n- <kbd>q</kbd> or <kbd>^C</kbd>: exit the program\n\n## Configuration\n\npipes-rs can be configured using TOML located at `~/.config/pipes-rs/config.toml`.\nThe following is an example file with the default settings:\n\n```toml\nbold = true\ncolor_mode = \"ansi\" # ansi, rgb or none\npalette = \"default\" # default, darker, pastel or matrix\nrainbow = 0 # 0-255\ndelay_ms = 20\ninherit_style = false\nkinds = [\"heavy\"] # heavy, light, curved, knobby, emoji, outline, dots, blocks, sus\nnum_pipes = 1\nreset_threshold = 0.5 # 0.0–1.0\nturn_chance = 0.15 # 0.0–1.0\n```\n\n### Color Modes\n\n| Mode   | Description                                                                       |\n| :----- | :-------------------------------------------------------------------------------- |\n| `ansi` | pipe colors are randomly selected from the terminal color profile, default option |\n| `rgb`  | pipe colors are randomly generated rgb values, unsupported in some terminals      |\n| `none` | pipe colors will not be set and use the current terminal text color               |\n\n### Palettes\n\n| Palette   | Description                                                      |\n| :-------- | :--------------------------------------------------------------- |\n| `default` | bright colors – good on dark backgrounds, default option         |\n| `darker`  | darker colors – good on light backgrounds                        |\n| `pastel`  | pastel colors – good on dark backgrounds                         |\n| `matrix`  | colors based on [Matrix digital rain] – good on dark backgrounds |\n\n### Pipe Kinds\n\n| Kind      | Preview                   |\n| :-------- | :------------------------ |\n| `heavy`   | `┃ ┃ ━ ━ ┏ ┓ ┗ ┛`         |\n| `light`   | `│ │ ─ ─ ┌ ┐ └ ┘`         |\n| `curved`  | `│ │ ─ ─ ╭ ╮ ╰ ╯`         |\n| `knobby`  | `╽ ╿ ╼ ╾ ┎ ┒ ┖ ┚`         |\n| `emoji`   | `👆 👇 👈 👉 👌 👌 👌 👌` |\n| `outline` | `║ ║ ═ ═ ╔ ╗ ╚ ╝`         |\n| `dots`    | `• • • • • • • •`         |\n| `blocks`  | `█ █ ▀ ▀ █ █ ▀ ▀`         |\n| `sus`     | `ඞ ඞ ඞ ඞ ඞ ඞ ඞ ඞ`         |\n\n_Due to emojis having a different character width, using the emoji pipe kind along side another pipe kind can cause spacing issues._\n\n## Options\n\nThere are also command line options that can be used to override parts of the configuration file:\n\n| Option      | Usage                                                                             | Example            |\n| :---------- | :-------------------------------------------------------------------------------- | :----------------- |\n| `-b`        | toggles bold text                                                                 | `-b true`          |\n| `-c`        | sets the color mode                                                               | `-c rgb`           |\n| `-d`        | sets the delay in ms                                                              | `-d 15`            |\n| `-i`        | toggles if pipes inherit style when hitting the edge                              | `-i false`         |\n| `-k`        | sets the kinds of pipes, each kind separated by commas                            | `-k heavy,curved`  |\n| `-p`        | sets the number of pipes on screen                                                | `-p 5`             |\n| `-r`        | sets the percentage of the screen to be filled before resetting                   | `-r 0.75`          |\n| `-t`        | chance of a pipe turning each frame                                               | `-t 0.15`          |\n| `--palette` | sets the color palette, RGB mode only                                             | `--palette pastel` |\n| `--rainbow` | sets the number of degrees per frame to shift the hue of each pipe, RGB mode only | `--rainbow 5`      |\n\n## Credits\n\n### Contributors\n\npipes-rs is maintained by [lhvy](https://github.com/lhvy) and [lunacookies](https://github.com/lunacookies); any other contributions via PRs are welcome! Forks and modifications are implicitly licensed under the Blue Oak Model License 1.0.0. Please credit the above contributors and pipes.sh when making forks or other derivative works.\n\n### Inspiration\n\nThis project is based off of [pipes.sh](https://github.com/pipeseroni/pipes.sh).\n\n[matrix digital rain]: https://en.wikipedia.org/wiki/Matrix_digital_rain\n"
  },
  {
    "path": "crates/model/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"BlueOak-1.0.0\"\nname = \"model\"\nversion = \"0.0.0\"\n\n[dependencies]\nanyhow = \"1.0.70\"\nrng = {path = \"../rng\"}\nserde = {version = \"1.0.159\", features = [\"derive\"]}\nterminal = {path = \"../terminal\"}\ntincture = \"1.0.0\"\n"
  },
  {
    "path": "crates/model/src/direction.rs",
    "content": "#[derive(Clone, Copy, PartialEq)]\npub(crate) enum Direction {\n    Up,\n    Down,\n    Left,\n    Right,\n}\n\nimpl Direction {\n    pub(crate) fn maybe_turn(self, turn_chance: f32) -> Direction {\n        if !rng::gen_bool(turn_chance) {\n            return self;\n        }\n\n        if rng::gen_bool(0.5) {\n            // turn left\n            match self {\n                Direction::Up => Direction::Left,\n                Direction::Down => Direction::Right,\n                Direction::Left => Direction::Up,\n                Direction::Right => Direction::Down,\n            }\n        } else {\n            // turn right\n            match self {\n                Direction::Up => Direction::Right,\n                Direction::Down => Direction::Left,\n                Direction::Left => Direction::Down,\n                Direction::Right => Direction::Up,\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "crates/model/src/lib.rs",
    "content": "mod direction;\npub mod pipe;\npub mod position;\n"
  },
  {
    "path": "crates/model/src/pipe/color.rs",
    "content": "use std::ops::Range;\n\n#[derive(Clone, Copy)]\npub struct Color {\n    pub terminal: terminal::Color,\n    pub(crate) oklch: Option<tincture::Oklch>,\n}\n\nimpl Color {\n    pub(crate) fn update(&mut self, hue_shift: f32) {\n        if let Some(oklch) = &mut self.oklch {\n            oklch.h += hue_shift.to_radians();\n            let oklab = tincture::oklch_to_oklab(*oklch);\n            let lrgb = tincture::oklab_to_linear_srgb(oklab);\n            let srgb = tincture::linear_srgb_to_srgb(lrgb);\n            self.terminal = terminal::Color::Rgb {\n                r: (srgb.r * 255.0) as u8,\n                g: (srgb.g * 255.0) as u8,\n                b: (srgb.b * 255.0) as u8,\n            };\n        }\n    }\n}\n\npub(super) fn gen_random_color(color_mode: ColorMode, palette: Palette) -> Option<Color> {\n    match color_mode {\n        ColorMode::Ansi => Some(gen_random_ansi_color()),\n        ColorMode::Rgb => Some(gen_random_rgb_color(palette)),\n        ColorMode::None => None,\n    }\n}\n\nfn gen_random_ansi_color() -> Color {\n    let num = rng::gen_range(0..12);\n\n    Color {\n        terminal: match num {\n            0 => terminal::Color::Red,\n            1 => terminal::Color::DarkRed,\n            2 => terminal::Color::Green,\n            3 => terminal::Color::DarkGreen,\n            4 => terminal::Color::Yellow,\n            5 => terminal::Color::DarkYellow,\n            6 => terminal::Color::Blue,\n            7 => terminal::Color::DarkBlue,\n            8 => terminal::Color::Magenta,\n            9 => terminal::Color::DarkMagenta,\n            10 => terminal::Color::Cyan,\n            11 => terminal::Color::DarkCyan,\n            _ => unreachable!(),\n        },\n        oklch: None,\n    }\n}\n\nfn gen_random_rgb_color(palette: Palette) -> Color {\n    let hue = rng::gen_range_float(palette.get_hue_range());\n    let lightness = rng::gen_range_float(palette.get_lightness_range());\n\n    let oklch = tincture::Oklch {\n        l: lightness,\n        c: palette.get_chroma(),\n        h: hue.to_radians(),\n    };\n    let oklab = tincture::oklch_to_oklab(oklch);\n    let lrgb = tincture::oklab_to_linear_srgb(oklab);\n    let srgb = tincture::linear_srgb_to_srgb(lrgb);\n    debug_assert!(\n        (0.0..=1.0).contains(&srgb.r)\n            && (0.0..=1.0).contains(&srgb.g)\n            && (0.0..=1.0).contains(&srgb.b)\n    );\n\n    Color {\n        terminal: terminal::Color::Rgb {\n            r: (srgb.r * 255.0) as u8,\n            g: (srgb.g * 255.0) as u8,\n            b: (srgb.b * 255.0) as u8,\n        },\n        oklch: Some(oklch),\n    }\n}\n\n#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum ColorMode {\n    Ansi,\n    Rgb,\n    None,\n}\n\n#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum Palette {\n    Default,\n    Darker,\n    Pastel,\n    Matrix,\n}\n\nimpl Palette {\n    pub(super) fn get_hue_range(self) -> Range<f32> {\n        match self {\n            Self::Matrix => 145.0..145.0,\n            _ => 0.0..360.0,\n        }\n    }\n\n    pub(super) fn get_lightness_range(self) -> Range<f32> {\n        match self {\n            Self::Default => 0.75..0.75,\n            Self::Darker => 0.65..0.65,\n            Self::Pastel => 0.8..0.8,\n            Self::Matrix => 0.5..0.9,\n        }\n    }\n\n    pub(super) fn get_chroma(self) -> f32 {\n        match self {\n            Self::Default => 0.125,\n            Self::Darker => 0.11,\n            Self::Pastel => 0.085,\n            Self::Matrix => 0.11,\n        }\n    }\n}\n"
  },
  {
    "path": "crates/model/src/pipe/kind.rs",
    "content": "use std::num::NonZeroUsize;\nuse std::str::FromStr;\n\n#[derive(serde::Serialize, serde::Deserialize, Eq, PartialEq, Hash, Clone, Copy)]\n#[serde(rename_all = \"snake_case\")]\npub enum Kind {\n    Heavy,\n    Light,\n    Curved,\n    Knobby,\n    Emoji,\n    Outline,\n    Dots,\n    Blocks,\n    Sus,\n}\n\nimpl Kind {\n    pub fn up(self) -> char {\n        self.chars()[0]\n    }\n\n    pub fn down(self) -> char {\n        self.chars()[1]\n    }\n\n    pub fn left(self) -> char {\n        self.chars()[2]\n    }\n\n    pub fn right(self) -> char {\n        self.chars()[3]\n    }\n\n    pub fn top_left(self) -> char {\n        self.chars()[4]\n    }\n\n    pub fn top_right(self) -> char {\n        self.chars()[5]\n    }\n\n    pub fn bottom_left(self) -> char {\n        self.chars()[6]\n    }\n\n    pub fn bottom_right(self) -> char {\n        self.chars()[7]\n    }\n\n    fn chars(self) -> [char; 8] {\n        match self {\n            Self::Heavy => Self::HEAVY,\n            Self::Light => Self::LIGHT,\n            Self::Curved => Self::CURVED,\n            Self::Knobby => Self::KNOBBY,\n            Self::Emoji => Self::EMOJI,\n            Self::Outline => Self::OUTLINE,\n            Self::Dots => Self::DOTS,\n            Self::Blocks => Self::BLOCKS,\n            Self::Sus => Self::SUS,\n        }\n    }\n\n    fn width(self) -> KindWidth {\n        match self {\n            Self::Dots | Self::Sus => KindWidth::Custom(NonZeroUsize::new(2).unwrap()),\n            _ => KindWidth::Auto,\n        }\n    }\n\n    const HEAVY: [char; 8] = ['┃', '┃', '━', '━', '┏', '┓', '┗', '┛'];\n    const LIGHT: [char; 8] = ['│', '│', '─', '─', '┌', '┐', '└', '┘'];\n    const CURVED: [char; 8] = ['│', '│', '─', '─', '╭', '╮', '╰', '╯'];\n    const KNOBBY: [char; 8] = ['╽', '╿', '╼', '╾', '┎', '┒', '┖', '┚'];\n    const EMOJI: [char; 8] = ['👆', '👇', '👈', '👉', '👌', '👌', '👌', '👌'];\n    const OUTLINE: [char; 8] = ['║', '║', '═', '═', '╔', '╗', '╚', '╝'];\n    const DOTS: [char; 8] = ['•', '•', '•', '•', '•', '•', '•', '•'];\n    const BLOCKS: [char; 8] = ['█', '█', '▀', '▀', '█', '█', '▀', '▀'];\n    const SUS: [char; 8] = ['ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ', 'ඞ'];\n}\n\n#[derive(Clone, Copy)]\nenum KindWidth {\n    Auto,\n    Custom(NonZeroUsize),\n}\n\n#[derive(serde::Serialize, serde::Deserialize, Clone)]\npub struct KindSet(Vec<Kind>);\n\nimpl FromStr for KindSet {\n    type Err = anyhow::Error;\n\n    fn from_str(s: &str) -> Result<Self, Self::Err> {\n        let mut set = Vec::new();\n\n        for kind in s.split(',') {\n            let kind = match kind {\n                \"heavy\" => Kind::Heavy,\n                \"light\" => Kind::Light,\n                \"curved\" => Kind::Curved,\n                \"knobby\" => Kind::Knobby,\n                \"emoji\" => Kind::Emoji,\n                \"outline\" => Kind::Outline,\n                \"dots\" => Kind::Dots,\n                \"blocks\" => Kind::Blocks,\n                \"sus\" => Kind::Sus,\n                _ => anyhow::bail!(\n                    r#\"unknown pipe kind (expected “heavy”, “light”, “curved”, “knobby”, “emoji”, “outline”, “dots”, “blocks”, or “sus”)\"#,\n                ),\n            };\n\n            if !set.contains(&kind) {\n                set.push(kind);\n            }\n        }\n\n        Ok(Self(set))\n    }\n}\n\nimpl KindSet {\n    pub fn from_one(kind: Kind) -> Self {\n        Self(vec![kind])\n    }\n\n    pub fn choose_random(&self) -> Kind {\n        let idx = rng::gen_range(0..self.0.len() as u32);\n        self.0[idx as usize]\n    }\n\n    pub fn chars(&self) -> impl Iterator<Item = char> + '_ {\n        self.0.iter().flat_map(|kind| kind.chars())\n    }\n\n    pub fn custom_widths(&self) -> impl Iterator<Item = NonZeroUsize> + '_ {\n        self.0.iter().filter_map(|kind| match kind.width() {\n            KindWidth::Custom(n) => Some(n),\n            KindWidth::Auto => None,\n        })\n    }\n}\n"
  },
  {
    "path": "crates/model/src/pipe.rs",
    "content": "mod color;\nmod kind;\n\npub use color::{ColorMode, Palette};\npub use kind::{Kind, KindSet};\n\nuse self::color::Color;\nuse crate::direction::Direction;\nuse crate::position::{InScreenBounds, Position};\n\npub struct Pipe {\n    current_direction: Direction,\n    previous_direction: Direction,\n    pub position: Position,\n    pub color: Option<Color>,\n    kind: Kind,\n}\n\nimpl Pipe {\n    pub fn new(size: (u16, u16), color_mode: ColorMode, palette: Palette, kind: Kind) -> Self {\n        let color = color::gen_random_color(color_mode, palette);\n        let (direction, position) = gen_random_direction_and_position(size);\n\n        Self {\n            current_direction: direction,\n            previous_direction: direction,\n            position,\n            color,\n            kind,\n        }\n    }\n\n    pub fn dup(&self, size: (u16, u16)) -> Self {\n        let (direction, position) = gen_random_direction_and_position(size);\n\n        Self {\n            current_direction: direction,\n            previous_direction: direction,\n            position,\n            color: self.color,\n            kind: self.kind,\n        }\n    }\n\n    pub fn tick(&mut self, size: (u16, u16), turn_chance: f32, hue_shift: u8) -> InScreenBounds {\n        let InScreenBounds(in_screen_bounds) = self.position.move_in(self.current_direction, size);\n\n        if let Some(color) = &mut self.color {\n            color.update(hue_shift.into());\n        }\n\n        if !in_screen_bounds {\n            return InScreenBounds(false);\n        }\n\n        self.previous_direction = self.current_direction;\n        self.current_direction = self.current_direction.maybe_turn(turn_chance);\n\n        InScreenBounds(true)\n    }\n\n    pub fn to_char(&self) -> char {\n        match (self.previous_direction, self.current_direction) {\n            (Direction::Up, Direction::Left) | (Direction::Right, Direction::Down) => {\n                self.kind.top_right()\n            }\n            (Direction::Up, Direction::Right) | (Direction::Left, Direction::Down) => {\n                self.kind.top_left()\n            }\n            (Direction::Down, Direction::Left) | (Direction::Right, Direction::Up) => {\n                self.kind.bottom_right()\n            }\n            (Direction::Down, Direction::Right) | (Direction::Left, Direction::Up) => {\n                self.kind.bottom_left()\n            }\n            (Direction::Up, Direction::Up) => self.kind.up(),\n            (Direction::Down, Direction::Down) => self.kind.down(),\n            (Direction::Left, Direction::Left) => self.kind.left(),\n            (Direction::Right, Direction::Right) => self.kind.right(),\n            _ => unreachable!(),\n        }\n    }\n}\n\nfn gen_random_direction_and_position((columns, rows): (u16, u16)) -> (Direction, Position) {\n    let direction = match rng::gen_range(0..4) {\n        0 => Direction::Up,\n        1 => Direction::Down,\n        2 => Direction::Left,\n        3 => Direction::Right,\n        _ => unreachable!(),\n    };\n\n    let position = match direction {\n        Direction::Up => Position {\n            x: rng::gen_range_16(0..columns),\n            y: rows - 1,\n        },\n        Direction::Down => Position {\n            x: rng::gen_range_16(0..columns),\n            y: 0,\n        },\n        Direction::Left => Position {\n            x: columns - 1,\n            y: rng::gen_range_16(0..rows),\n        },\n        Direction::Right => Position {\n            x: 0,\n            y: rng::gen_range_16(0..rows),\n        },\n    };\n\n    (direction, position)\n}\n"
  },
  {
    "path": "crates/model/src/position.rs",
    "content": "use crate::direction::Direction;\n\npub struct Position {\n    pub x: u16,\n    pub y: u16,\n}\n\nimpl Position {\n    pub(crate) fn move_in(&mut self, dir: Direction, size: (u16, u16)) -> InScreenBounds {\n        match dir {\n            Direction::Up => {\n                if self.y == 0 {\n                    return InScreenBounds(false);\n                }\n                self.y -= 1;\n            }\n            Direction::Down => self.y += 1,\n            Direction::Left => {\n                if self.x == 0 {\n                    return InScreenBounds(false);\n                }\n                self.x -= 1;\n            }\n            Direction::Right => self.x += 1,\n        }\n\n        InScreenBounds(self.in_screen_bounds(size))\n    }\n\n    fn in_screen_bounds(&self, (columns, rows): (u16, u16)) -> bool {\n        self.x < columns && self.y < rows\n    }\n}\n\n#[derive(PartialEq, Eq)]\npub struct InScreenBounds(pub bool);\n"
  },
  {
    "path": "crates/pipes-rs/Cargo.toml",
    "content": "[package]\ndescription = \"An over-engineered rewrite of pipes.sh in Rust\"\nedition = \"2021\"\nlicense = \"BlueOak-1.0.0\"\nname = \"pipes-rs\"\nversion = \"1.6.4\"\n\n[dependencies]\nanyhow = \"1.0.70\"\nhome = \"0.5.5\"\nmimalloc = { version = \"0.1.36\", default-features = false }\nmodel = { path = \"../model\" }\nrng = { path = \"../rng\" }\nserde = \"1.0.159\"\nterminal = { path = \"../terminal\" }\ntoml = \"0.8.2\"\n"
  },
  {
    "path": "crates/pipes-rs/src/config.rs",
    "content": "use anyhow::Context;\nuse model::pipe::{ColorMode, Kind, KindSet, Palette};\nuse serde::{Deserialize, Serialize};\nuse std::path::PathBuf;\nuse std::time::Duration;\nuse std::{env, fs};\n\n#[derive(Serialize, Deserialize, Default)]\npub struct Config {\n    pub color_mode: Option<ColorMode>,\n    pub palette: Option<Palette>,\n    pub rainbow: Option<u8>,\n    pub delay_ms: Option<u64>,\n    pub fps: Option<f32>,\n    pub reset_threshold: Option<f32>,\n    pub kinds: Option<KindSet>,\n    pub bold: Option<bool>,\n    pub inherit_style: Option<bool>,\n    pub num_pipes: Option<u32>,\n    pub turn_chance: Option<f32>,\n}\n\nimpl Config {\n    pub fn read() -> anyhow::Result<Self> {\n        let config = Self::read_from_disk_with_default()?;\n        config.validate()?;\n\n        Ok(config)\n    }\n\n    fn read_from_disk_with_default() -> anyhow::Result<Self> {\n        let path = Self::path()?;\n\n        if !path.exists() {\n            return Ok(Config::default());\n        }\n\n        Self::read_from_disk(path)\n    }\n\n    fn path() -> anyhow::Result<PathBuf> {\n        let config_dir = 'config_dir: {\n            if let Ok(d) = env::var(\"XDG_CONFIG_HOME\") {\n                let d = PathBuf::from(d);\n                if d.is_absolute() {\n                    break 'config_dir d;\n                }\n            }\n\n            match home::home_dir() {\n                Some(d) => d.join(\".config/\"),\n                None => anyhow::bail!(\"could not determine home directory\"),\n            }\n        };\n\n        Ok(config_dir.join(\"pipes-rs/config.toml\"))\n    }\n\n    fn read_from_disk(path: PathBuf) -> anyhow::Result<Self> {\n        let contents = fs::read_to_string(path)?;\n        toml::from_str(&contents).context(\"failed to read config\")\n    }\n\n    pub fn validate(&self) -> anyhow::Result<()> {\n        if let Some(reset_threshold) = self.reset_threshold() {\n            if !(0.0..=1.0).contains(&reset_threshold) {\n                anyhow::bail!(\"reset threshold should be within 0 and 1\")\n            }\n        }\n\n        if !(0.0..=1.0).contains(&self.turn_chance()) {\n            anyhow::bail!(\"turn chance should be within 0 and 1\")\n        }\n\n        if self.delay_ms.is_some() && self.fps.is_some() {\n            anyhow::bail!(\"both delay and FPS can’t be set simultaneously\");\n        }\n\n        Ok(())\n    }\n\n    pub fn color_mode(&self) -> ColorMode {\n        self.color_mode.unwrap_or(ColorMode::Ansi)\n    }\n\n    pub fn palette(&self) -> Palette {\n        self.palette.unwrap_or(Palette::Default)\n    }\n\n    pub fn rainbow(&self) -> u8 {\n        self.rainbow.unwrap_or(0)\n    }\n\n    pub fn tick_length(&self) -> Duration {\n        if let Some(fps) = self.fps {\n            if fps == 0.0 {\n                return Duration::ZERO;\n            }\n            return Duration::from_secs_f32(1.0 / fps);\n        }\n\n        if let Some(delay_ms) = self.delay_ms {\n            return Duration::from_millis(delay_ms); // assume rendering a frame takes no time\n        }\n\n        Duration::from_secs_f32(1.0 / 50.0) // default to 50 FPS\n    }\n\n    pub fn reset_threshold(&self) -> Option<f32> {\n        match self.reset_threshold {\n            Some(0.0) => None,\n            Some(n) => Some(n),\n            None => Some(0.5),\n        }\n    }\n\n    pub fn kinds(&self) -> KindSet {\n        self.kinds\n            .clone()\n            .unwrap_or_else(|| KindSet::from_one(Kind::Heavy))\n    }\n\n    pub fn bold(&self) -> bool {\n        self.bold.unwrap_or(true)\n    }\n\n    pub fn inherit_style(&self) -> bool {\n        self.inherit_style.unwrap_or(false)\n    }\n\n    pub fn num_pipes(&self) -> u32 {\n        self.num_pipes.unwrap_or(1)\n    }\n\n    pub fn turn_chance(&self) -> f32 {\n        self.turn_chance.unwrap_or(0.15)\n    }\n}\n"
  },
  {
    "path": "crates/pipes-rs/src/lib.rs",
    "content": "mod config;\npub use config::Config;\n\nuse model::pipe::{KindSet, Pipe};\nuse model::position::InScreenBounds;\nuse std::{io, thread, time};\nuse terminal::{Event, Terminal};\n\npub struct App {\n    terminal: Terminal,\n    config: Config,\n    kinds: KindSet,\n}\n\nimpl App {\n    pub fn new(config: Config) -> anyhow::Result<Self> {\n        let kinds = config.kinds();\n\n        let stdout = io::stdout().lock();\n        let largest_custom_width = kinds.custom_widths().max();\n        let terminal = Terminal::new(stdout, kinds.chars(), largest_custom_width)?;\n\n        Ok(Self {\n            terminal,\n            config,\n            kinds,\n        })\n    }\n\n    pub fn run(mut self) -> anyhow::Result<()> {\n        self.terminal.enter_alternate_screen()?;\n        self.terminal.set_raw_mode(true)?;\n        self.terminal.set_cursor_visibility(false)?;\n        if self.config.bold() {\n            self.terminal.enable_bold()?;\n        }\n\n        let mut pipes = self.create_pipes();\n\n        loop {\n            if let ControlFlow::Break = self.reset_loop(&mut pipes)? {\n                break;\n            }\n        }\n\n        self.terminal.set_raw_mode(false)?;\n        self.terminal.set_cursor_visibility(true)?;\n        self.terminal.leave_alternate_screen()?;\n\n        Ok(())\n    }\n\n    pub fn reset_loop(&mut self, pipes: &mut Vec<Pipe>) -> anyhow::Result<ControlFlow> {\n        self.terminal.clear()?;\n\n        for pipe in &mut *pipes {\n            *pipe = self.create_pipe();\n        }\n\n        while self.under_threshold() {\n            let control_flow = self.tick_loop(pipes)?;\n            match control_flow {\n                ControlFlow::Break | ControlFlow::Reset => return Ok(control_flow),\n                ControlFlow::Continue => {}\n            }\n        }\n\n        Ok(ControlFlow::Continue)\n    }\n\n    pub fn tick_loop(&mut self, pipes: &mut Vec<Pipe>) -> anyhow::Result<ControlFlow> {\n        let start_time = time::Instant::now();\n\n        match self.terminal.get_event()? {\n            Some(Event::Exit) => return Ok(ControlFlow::Break),\n            Some(Event::Reset) => return Ok(ControlFlow::Reset),\n            None => {}\n        }\n\n        for pipe in pipes {\n            self.render_pipe(pipe)?;\n            self.tick_pipe(pipe);\n        }\n\n        self.terminal.flush()?;\n\n        let tick_length_so_far = start_time.elapsed();\n\n        // If we’ve taken more time than we have a budget for,\n        // then just don’t sleep.\n        let took_too_long = tick_length_so_far >= self.config.tick_length();\n        if !took_too_long {\n            thread::sleep(self.config.tick_length() - tick_length_so_far);\n        }\n\n        Ok(ControlFlow::Continue)\n    }\n\n    fn tick_pipe(&mut self, pipe: &mut Pipe) {\n        let InScreenBounds(stayed_onscreen) = pipe.tick(\n            self.terminal.size(),\n            self.config.turn_chance(),\n            self.config.rainbow(),\n        );\n\n        if !stayed_onscreen {\n            *pipe = if self.config.inherit_style() {\n                pipe.dup(self.terminal.size())\n            } else {\n                self.create_pipe()\n            };\n        }\n    }\n\n    fn render_pipe(&mut self, pipe: &Pipe) -> anyhow::Result<()> {\n        self.terminal\n            .move_cursor_to(pipe.position.x, pipe.position.y)?;\n\n        if let Some(color) = pipe.color {\n            self.terminal.set_text_color(color.terminal)?;\n        }\n\n        self.terminal.print(if rng::gen_bool(0.99999) {\n            pipe.to_char()\n        } else {\n            '🦀'\n        })?;\n\n        Ok(())\n    }\n\n    pub fn create_pipes(&mut self) -> Vec<Pipe> {\n        (0..self.config.num_pipes())\n            .map(|_| self.create_pipe())\n            .collect()\n    }\n\n    fn create_pipe(&mut self) -> Pipe {\n        let kind = self.kinds.choose_random();\n\n        Pipe::new(\n            self.terminal.size(),\n            self.config.color_mode(),\n            self.config.palette(),\n            kind,\n        )\n    }\n\n    fn under_threshold(&self) -> bool {\n        match self.config.reset_threshold() {\n            Some(reset_threshold) => self.terminal.portion_covered() < reset_threshold,\n            None => true,\n        }\n    }\n}\n\n#[must_use]\npub enum ControlFlow {\n    Continue,\n    Break,\n    Reset,\n}\n"
  },
  {
    "path": "crates/pipes-rs/src/main.rs",
    "content": "use mimalloc::MiMalloc;\nuse model::pipe::{ColorMode, Palette};\nuse pipes_rs::{App, Config};\nuse std::{env, process};\n\n#[global_allocator]\nstatic GLOBAL: MiMalloc = MiMalloc;\n\nfn main() -> anyhow::Result<()> {\n    let mut config = Config::read()?;\n    parse_args(&mut config);\n    config.validate()?;\n\n    let app = App::new(config)?;\n    app.run()?;\n\n    Ok(())\n}\n\nfn parse_args(config: &mut Config) {\n    let args: Vec<_> = env::args().skip(1).collect();\n    let mut args_i = args.iter();\n\n    while let Some(arg) = args_i.next() {\n        match arg.as_str() {\n            \"--license\" => {\n                if args.len() != 1 {\n                    eprintln!(\"error: provided arguments other than --license\");\n                    process::exit(1);\n                }\n\n                println!(\"pipes-rs is licensed under the Blue Oak Model License 1.0.0,\");\n                println!(\"the text of which you will find below.\");\n                println!(\"\\n{}\", include_str!(\"../../../LICENSE.md\"));\n                process::exit(0);\n            }\n\n            \"--version\" | \"-V\" => {\n                if args.len() != 1 {\n                    eprintln!(\"error: provided arguments other than --version\");\n                    process::exit(1);\n                }\n\n                println!(\"pipes-rs {}\", env!(\"CARGO_PKG_VERSION\"));\n                process::exit(0);\n            }\n\n            \"--help\" | \"-h\" => {\n                println!(\"{}\", include_str!(\"usage\"));\n                process::exit(0);\n            }\n\n            _ => {}\n        }\n\n        if !arg.starts_with('-') {\n            eprintln!(\"error: unexpected argument “{arg}” found\");\n            eprintln!(\"see --help\");\n            process::exit(1);\n        }\n\n        let (option, value) = arg.split_once('=').unwrap_or_else(|| match args_i.next() {\n            Some(value) => (arg, value),\n            None => required_value(arg),\n        });\n\n        match option {\n            \"--color-mode\" | \"-c\" => {\n                config.color_mode = match value {\n                    \"ansi\" => Some(ColorMode::Ansi),\n                    \"rgb\" => Some(ColorMode::Rgb),\n                    \"none\" => Some(ColorMode::None),\n                    _ => invalid_value(option, value, \"“ansi”, “rgb” or “none”\"),\n                }\n            }\n\n            \"--palette\" => {\n                config.palette = match value {\n                    \"default\" => Some(Palette::Default),\n                    \"darker\" => Some(Palette::Darker),\n                    \"pastel\" => Some(Palette::Pastel),\n                    \"matrix\" => Some(Palette::Matrix),\n                    _ => invalid_value(option, value, \"“default”, “darker”, “pastel” or “matrix”\"),\n                }\n            }\n\n            \"--rainbow\" => {\n                config.rainbow = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"an integer between 0 and 255\"),\n                }\n            }\n\n            \"--delay\" | \"-d\" => {\n                config.delay_ms = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"a positive integer\"),\n                }\n            }\n\n            \"--fps\" | \"-f\" => {\n                config.fps = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"a number\"),\n                }\n            }\n\n            \"--reset-threshold\" | \"-r\" => {\n                config.reset_threshold = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"a number\"),\n                }\n            }\n\n            \"--kinds\" | \"-k\" => {\n                config.kinds = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"kinds of pipes separated by commas\"),\n                }\n            }\n\n            \"--bold\" | \"-b\" => {\n                config.bold = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"“true” or “false”\"),\n                }\n            }\n\n            \"--inherit-style\" | \"-i\" => {\n                config.inherit_style = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"“true” or “false”\"),\n                }\n            }\n\n            \"--pipe-num\" | \"-p\" => {\n                config.num_pipes = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"a positive integer\"),\n                }\n            }\n\n            \"--turn-chance\" | \"-t\" => {\n                config.turn_chance = match value.parse() {\n                    Ok(v) => Some(v),\n                    Err(_) => invalid_value(option, value, \"a number\"),\n                }\n            }\n\n            _ => {\n                eprintln!(\"error: unrecognized option {option}\");\n                eprintln!(\"see --help\");\n                process::exit(1);\n            }\n        }\n    }\n}\n\nfn required_value(option: &str) -> ! {\n    eprintln!(\"error: a value is required for {option} but none was supplied\");\n    eprintln!(\"see --help\");\n    process::exit(1);\n}\n\nfn invalid_value(option: &str, actual: &str, expected: &str) -> ! {\n    eprintln!(\"error: invalid value “{actual}” for {option}\");\n    eprintln!(\"       expected {expected}\");\n    eprintln!(\"see --help\");\n    process::exit(1);\n}\n"
  },
  {
    "path": "crates/pipes-rs/src/usage",
    "content": "An over-engineered rewrite of pipes.sh in Rust.\n\nUsage: pipes-rs [OPTIONS]\n\nOptions:\n  -c, --color-mode <COLOR_MODE>            what kind of terminal coloring to use\n      --palette <PALETTE>                  the color palette used assign colors to pipes\n      --rainbow <DEGREES>                  cycle hue of pipes\n  -d, --delay <DELAY_MS>                   delay between frames in milliseconds\n  -f, --fps <FPS>                          number of frames of animation that are displayed in a second; use 0 for unlimited\n  -r, --reset-threshold <RESET_THRESHOLD>  portion of screen covered before resetting (0.0–1.0)\n  -k, --kinds <KINDS>                      kinds of pipes separated by commas, e.g. heavy,curved\n  -b, --bold <BOOL>                        whether to use bold [possible values: true, false]\n  -i, --inherit-style <BOOL>               whether pipes should retain style after hitting the edge [possible values: true, false]\n  -p, --pipe-num <NUM>                     number of pipes\n  -t, --turn-chance <TURN_CHANCE>          chance of a pipe turning (0.0–1.0)\n      --license                            Print license\n  -h, --help                               Print help\n  -V, --version                            Print version\n"
  },
  {
    "path": "crates/rng/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"BlueOak-1.0.0\"\nname = \"rng\"\nversion = \"0.0.0\"\n\n[dependencies]\nonce_cell = \"1.17.1\"\noorandom = \"11.1.3\"\nparking_lot = \"0.12.1\"\n"
  },
  {
    "path": "crates/rng/src/lib.rs",
    "content": "use once_cell::sync::Lazy;\nuse parking_lot::Mutex;\nuse std::ops::Range;\n\nstatic RNG: Lazy<Mutex<Rng>> = Lazy::new(|| {\n    let seed = std::time::SystemTime::now()\n        .duration_since(std::time::UNIX_EPOCH)\n        .expect(\"system time cannot be before unix epoch\")\n        .as_millis() as u64;\n\n    Mutex::new(Rng {\n        rand_32: oorandom::Rand32::new(seed),\n    })\n});\n\nstruct Rng {\n    rand_32: oorandom::Rand32,\n}\n\npub fn gen_range(range: Range<u32>) -> u32 {\n    RNG.lock().rand_32.rand_range(range)\n}\n\npub fn gen_range_float(range: Range<f32>) -> f32 {\n    RNG.lock().rand_32.rand_float() * (range.end - range.start) + range.start\n}\n\npub fn gen_range_16(range: Range<u16>) -> u16 {\n    RNG.lock()\n        .rand_32\n        .rand_range(range.start as u32..range.end as u32) as u16\n}\n\npub fn gen_bool(probability: f32) -> bool {\n    assert!(probability >= 0.0);\n    assert!(probability <= 1.0);\n\n    RNG.lock().rand_32.rand_float() < probability\n}\n"
  },
  {
    "path": "crates/terminal/Cargo.toml",
    "content": "[package]\nedition = \"2021\"\nlicense = \"BlueOak-1.0.0\"\nname = \"terminal\"\nversion = \"0.0.0\"\n\n[dependencies]\nanyhow = \"1.0.70\"\ncrossterm = \"0.27.0\"\nunicode-width = \"0.1.10\"\n"
  },
  {
    "path": "crates/terminal/src/lib.rs",
    "content": "mod screen;\n\nuse crossterm::event::{\n    self, Event as CrosstermEvent, KeyCode, KeyEvent, KeyEventKind, KeyModifiers,\n};\nuse crossterm::{cursor, queue, style, terminal};\nuse screen::Screen;\nuse std::io::{self, Write};\nuse std::num::NonZeroUsize;\nuse std::time::Duration;\nuse unicode_width::UnicodeWidthChar;\n\npub struct Terminal {\n    screen: Screen,\n    stdout: io::StdoutLock<'static>,\n    max_char_width: u16,\n    size: (u16, u16),\n}\n\nimpl Terminal {\n    pub fn new(\n        stdout: io::StdoutLock<'static>,\n        chars: impl Iterator<Item = char>,\n        custom_width: Option<NonZeroUsize>,\n    ) -> anyhow::Result<Self> {\n        let max_char_width = Self::determine_max_char_width(chars, custom_width);\n\n        let size = {\n            let (width, height) = terminal::size()?;\n            (width / max_char_width, height)\n        };\n\n        let screen = Screen::new(size.0 as usize, size.1 as usize);\n\n        Ok(Self {\n            screen,\n            stdout,\n            max_char_width,\n            size,\n        })\n    }\n\n    fn determine_max_char_width(\n        chars: impl Iterator<Item = char>,\n        custom_width: Option<NonZeroUsize>,\n    ) -> u16 {\n        let max_char_width = chars.map(|c| c.width().unwrap() as u16).max().unwrap();\n\n        match custom_width {\n            Some(custom_width) => max_char_width.max(custom_width.get() as u16),\n            None => max_char_width,\n        }\n    }\n\n    pub fn enable_bold(&mut self) -> anyhow::Result<()> {\n        queue!(self.stdout, style::SetAttribute(style::Attribute::Bold))?;\n        Ok(())\n    }\n\n    pub fn reset_style(&mut self) -> anyhow::Result<()> {\n        queue!(self.stdout, style::SetAttribute(style::Attribute::Reset))?;\n        Ok(())\n    }\n\n    pub fn set_cursor_visibility(&mut self, visible: bool) -> anyhow::Result<()> {\n        if visible {\n            queue!(self.stdout, cursor::Show)?;\n        } else {\n            queue!(self.stdout, cursor::Hide)?;\n        }\n\n        Ok(())\n    }\n\n    pub fn clear(&mut self) -> anyhow::Result<()> {\n        queue!(self.stdout, terminal::Clear(terminal::ClearType::All))?;\n        self.screen.clear();\n\n        Ok(())\n    }\n\n    pub fn set_raw_mode(&self, enabled: bool) -> anyhow::Result<()> {\n        if enabled {\n            terminal::enable_raw_mode()?;\n        } else {\n            terminal::disable_raw_mode()?;\n        }\n\n        Ok(())\n    }\n\n    pub fn enter_alternate_screen(&mut self) -> anyhow::Result<()> {\n        queue!(self.stdout, terminal::EnterAlternateScreen)?;\n        Ok(())\n    }\n\n    pub fn leave_alternate_screen(&mut self) -> anyhow::Result<()> {\n        queue!(self.stdout, terminal::LeaveAlternateScreen)?;\n        Ok(())\n    }\n\n    pub fn set_text_color(&mut self, color: Color) -> anyhow::Result<()> {\n        let color = style::Color::from(color);\n        queue!(self.stdout, style::SetForegroundColor(color))?;\n\n        Ok(())\n    }\n\n    pub fn move_cursor_to(&mut self, x: u16, y: u16) -> anyhow::Result<()> {\n        queue!(self.stdout, cursor::MoveTo(x * self.max_char_width, y))?;\n        self.screen.move_cursor_to(x as usize, y as usize);\n\n        Ok(())\n    }\n\n    pub fn portion_covered(&self) -> f32 {\n        self.screen.portion_covered()\n    }\n\n    pub fn size(&self) -> (u16, u16) {\n        self.size\n    }\n\n    pub fn print(&mut self, c: char) -> anyhow::Result<()> {\n        self.screen.print();\n        self.stdout.write_all(c.to_string().as_bytes())?;\n\n        Ok(())\n    }\n\n    pub fn flush(&mut self) -> anyhow::Result<()> {\n        self.stdout.flush()?;\n        Ok(())\n    }\n\n    pub fn get_event(&mut self) -> anyhow::Result<Option<Event>> {\n        if !event::poll(Duration::ZERO)? {\n            return Ok(None);\n        }\n\n        match event::read()? {\n            CrosstermEvent::Resize(width, height) => {\n                self.resize(width, height);\n                Ok(Some(Event::Reset))\n            }\n\n            CrosstermEvent::Key(\n                KeyEvent {\n                    code: KeyCode::Char('c'),\n                    modifiers: KeyModifiers::CONTROL,\n                    kind: KeyEventKind::Press,\n                    ..\n                }\n                | KeyEvent {\n                    code: KeyCode::Char('q'),\n                    kind: KeyEventKind::Press,\n                    ..\n                },\n            ) => Ok(Some(Event::Exit)),\n\n            CrosstermEvent::Key(KeyEvent {\n                code: KeyCode::Char('r'),\n                ..\n            }) => Ok(Some(Event::Reset)),\n\n            _ => Ok(None),\n        }\n    }\n\n    fn resize(&mut self, width: u16, height: u16) {\n        self.size = (width, height);\n        self.screen.resize(width as usize, height as usize);\n    }\n}\n\n#[derive(Clone, Copy)]\npub enum Color {\n    Red,\n    DarkRed,\n    Green,\n    DarkGreen,\n    Yellow,\n    DarkYellow,\n    Blue,\n    DarkBlue,\n    Magenta,\n    DarkMagenta,\n    Cyan,\n    DarkCyan,\n    Rgb { r: u8, g: u8, b: u8 },\n}\n\nimpl From<Color> for style::Color {\n    fn from(color: Color) -> Self {\n        match color {\n            Color::Red => Self::Red,\n            Color::DarkRed => Self::DarkRed,\n            Color::Green => Self::Green,\n            Color::DarkGreen => Self::DarkGreen,\n            Color::Yellow => Self::Yellow,\n            Color::DarkYellow => Self::DarkYellow,\n            Color::Blue => Self::Blue,\n            Color::DarkBlue => Self::DarkBlue,\n            Color::Magenta => Self::Magenta,\n            Color::DarkMagenta => Self::DarkMagenta,\n            Color::Cyan => Self::Cyan,\n            Color::DarkCyan => Self::DarkCyan,\n            Color::Rgb { r, g, b } => Self::Rgb { r, g, b },\n        }\n    }\n}\n\npub enum Event {\n    Exit,\n    Reset,\n}\n"
  },
  {
    "path": "crates/terminal/src/screen.rs",
    "content": "pub(crate) struct Screen {\n    cells: Vec<Cell>,\n    cursor: (usize, usize),\n    width: usize,\n    height: usize,\n    num_covered: usize,\n}\n\nimpl Screen {\n    pub(crate) fn new(width: usize, height: usize) -> Self {\n        Self {\n            cells: vec![Cell { is_covered: false }; width * height],\n            cursor: (0, 0),\n            width,\n            height,\n            num_covered: 0,\n        }\n    }\n\n    pub(crate) fn resize(&mut self, width: usize, height: usize) {\n        self.cells\n            .resize(width * height, Cell { is_covered: false });\n        self.cursor = (0, 0);\n        self.width = width;\n        self.height = height;\n        self.clear();\n    }\n\n    pub(crate) fn move_cursor_to(&mut self, x: usize, y: usize) {\n        assert!(x < self.width);\n        assert!(y < self.height);\n\n        self.cursor = (x, y);\n    }\n\n    pub(crate) fn print(&mut self) {\n        let current_cell = self.current_cell();\n        if !current_cell.is_covered {\n            current_cell.is_covered = true;\n            self.num_covered += 1;\n        }\n    }\n\n    pub(crate) fn clear(&mut self) {\n        for cell in &mut self.cells {\n            cell.is_covered = false;\n        }\n        self.num_covered = 0;\n    }\n\n    pub(crate) fn portion_covered(&self) -> f32 {\n        debug_assert_eq!(\n            self.num_covered,\n            self.cells.iter().filter(|c| c.is_covered).count()\n        );\n        self.num_covered as f32 / self.cells.len() as f32\n    }\n\n    fn current_cell(&mut self) -> &mut Cell {\n        &mut self.cells[self.cursor.1 * self.width + self.cursor.0]\n    }\n}\n\n#[derive(Clone, Copy)]\nstruct Cell {\n    is_covered: bool,\n}\n"
  }
]