summary refs log tree commit diff
diff options
context:
space:
mode:
authorIrene Knapp <ireneista@irenes.space>2026-03-26 23:45:52 -0700
committerIrene Knapp <ireneista@irenes.space>2026-03-26 23:46:35 -0700
commit2dc0e6470d65762df6c57e28cc32ee8b69844867 (patch)
treeefe4af98afbbefd6881632ef7614cbd8104a677e
initial commit; draws the traditional tildes
Force-Push: yes
Change-Id: I18ada127a4c78e5ac74f5002ed3716c53a1e141b
-rw-r--r--.envrc1
-rw-r--r--.gitignore5
-rw-r--r--Cargo.lock369
-rw-r--r--Cargo.toml9
-rw-r--r--flake.lock43
-rw-r--r--flake.nix36
-rw-r--r--src/main.rs253
7 files changed, 716 insertions, 0 deletions
diff --git a/.envrc b/.envrc
new file mode 100644
index 0000000..3550a30
--- /dev/null
+++ b/.envrc
@@ -0,0 +1 @@
+use flake
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a6e63d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+/result
+/result-*
+/target
+/.direnv
+*.swp
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..4c93618
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,369 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-fs"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5"
+dependencies = [
+ "async-lock",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix",
+ "slab",
+ "windows-sys",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-net"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7"
+dependencies = [
+ "async-io",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "ivy"
+version = "0.1.0"
+dependencies = [
+ "smol",
+ "termios",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smol"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f"
+dependencies = [
+ "async-channel",
+ "async-executor",
+ "async-fs",
+ "async-io",
+ "async-lock",
+ "async-net",
+ "async-process",
+ "blocking",
+ "futures-lite",
+]
+
+[[package]]
+name = "termios"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..782319c
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,9 @@
+[package]
+name = "ivy"
+version = "0.1.0"
+authors = ["Irene Knapp <ireneista@irenes.space>"]
+edition = "2024"
+
+[dependencies]
+smol = "2.0.2"
+termios = "0.3.3"
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 0000000..db6234f
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,43 @@
+{
+  "nodes": {
+    "crane": {
+      "locked": {
+        "lastModified": 1774313767,
+        "narHash": "sha256-hy0XTQND6avzGEUFrJtYBBpFa/POiiaGBr2vpU6Y9tY=",
+        "owner": "ipetkov",
+        "repo": "crane",
+        "rev": "3d9df76e29656c679c744968b17fbaf28f0e923d",
+        "type": "github"
+      },
+      "original": {
+        "owner": "ipetkov",
+        "repo": "crane",
+        "type": "github"
+      }
+    },
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1774388614,
+        "narHash": "sha256-tFwzTI0DdDzovdE9+Ras6CUss0yn8P9XV4Ja6RjA+nU=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "1073dad219cb244572b74da2b20c7fe39cb3fa9e",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-25.11",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "crane": "crane",
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 0000000..3109945
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,36 @@
+{
+  inputs = {
+    nixpkgs = {
+      type = "github";
+      owner = "NixOS";
+      repo = "nixpkgs";
+      ref = "nixos-25.11";
+    };
+
+    crane = {
+      url = "github:ipetkov/crane";
+    };
+  };
+
+  outputs = { self, nixpkgs, crane }:
+  let supportedSystems = [ "aarch64-linux" "x86_64-linux" ];
+      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
+      nixpkgsFor = forAllSystems (system: import nixpkgs { inherit system; });
+  in {
+    packages = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in {
+      default = (crane.mkLib pkgs).buildPackage {
+        src = ./.;
+      };
+    });
+
+    devShells = forAllSystems (system: let pkgs = nixpkgsFor.${system}; in {
+      default = pkgs.mkShell {
+        nativeBuildInputs = with pkgs; [
+          cargo
+          rustc
+        ];
+      };
+    });
+  };
+}
+
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..f24bcbc
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,253 @@
+#![forbid(unsafe_code)]
+use smol::prelude::*;
+
+use smol::{ unblock, Unblock, Timer };
+use smol::lock::{ OnceCell, RwLock };
+use std::fmt::Display;
+use std::os::fd::AsRawFd;
+use std::process::ExitCode;
+use std::time::Duration;
+use termios::Termios;
+
+type Error = std::io::Error;
+type Result<T> = std::result::Result<T, Error>;
+
+struct Ivy {
+  stdin: Unblock<std::io::Stdin>,
+  stdout: Unblock<std::io::Stdout>,
+  initial_termios: OnceCell<Termios>,
+  width: RwLock<u64>,
+  height: RwLock<u64>,
+}
+
+enum EscapeType {
+  CSI,
+  CSIPrivate,
+}
+
+impl EscapeType {
+  fn intro(&self) -> &'static str {
+    match self {
+      EscapeType::CSI => "\x1b[",
+      EscapeType::CSIPrivate => "\x1b[?",
+    }
+  }
+}
+
+
+fn main() -> ExitCode {
+  smol::block_on(async {
+    if let Err(e) = Ivy::new().await.run().await {
+      println!("{:?}", e);
+      ExitCode::FAILURE
+    } else {
+      ExitCode::SUCCESS
+    }
+  })
+}
+
+
+impl Ivy {
+  async fn new() -> Self {
+    Ivy {
+      stdin: Unblock::new(std::io::stdin()),
+      stdout: Unblock::new(std::io::stdout()),
+      initial_termios: OnceCell::new(),
+      width: RwLock::new(80),
+      height: RwLock::new(24),
+    }
+  }
+
+  async fn run(mut self) -> Result<()> {
+    let mut error = None;
+
+    error = error.or(self.init_termios().await.err());
+    error = error.or(self.init_full_screen().await.err());
+
+    error = error.or({
+      for _ in 1 .. *self.height.read().await {
+        self.stdout.write_all("\n~".as_bytes()).await?;
+      }
+
+      self.do_cursor_position(0, 0).await?;
+
+      self.stdout.flush().await?;
+
+      Ok(())
+    }.err());
+
+    Timer::after(Duration::from_millis(1000)).await;
+
+    error = error.or(self.zap_full_screen().await.err());
+    error = error.or(self.zap_termios().await.err());
+
+    if let Some(error) = error {
+      Err(error)
+    } else {
+      Ok(())
+    }
+  }
+
+  async fn init_termios(&mut self) -> Result<()> {
+    let _ = self.initial_termios.set(unblock(|| {
+      let fd = std::io::stdin().as_raw_fd();
+      Termios::from_fd(fd)
+    }).await?).await;
+
+    let mut termios = self.initial_termios.get().unwrap().clone();
+
+    termios.c_lflag &= !(termios::ECHO
+                         | termios::ECHONL
+                         | termios::os::linux::ECHOCTL
+                         | termios::ICANON);
+    termios.c_lflag |= termios::ISIG;
+
+    termios.c_cc[termios::VMIN] = 1;
+    termios.c_cc[termios::VTIME] = 0;
+
+    unblock(move || {
+      let fd = std::io::stdin().as_raw_fd();
+      termios::tcsetattr(fd, termios::TCSANOW, &mut termios)
+    }).await?;
+
+    Ok(())
+  }
+
+  async fn zap_termios(&mut self) -> Result<()> {
+    let mut termios = self.initial_termios.get().unwrap().clone();
+
+    unblock(move || {
+      let fd = std::io::stdin().as_raw_fd();
+      termios::tcsetattr(fd, termios::TCSANOW, &mut termios)
+    }).await?;
+
+    Ok(())
+  }
+
+  async fn init_full_screen(&mut self) -> Result<()> {
+    self.do_start_alternate_screen().await?;
+    self.do_cursor_position(0, 0).await?;
+
+    let (width, height) = self.do_report_size().await?;
+    *self.width.write().await = width;
+    *self.height.write().await = height;
+
+    Ok(())
+  }
+
+  async fn zap_full_screen(&mut self) -> Result<()> {
+    self.do_end_alternate_screen().await?;
+    self.stdout.flush().await?;
+
+    Ok(())
+  }
+
+  async fn do_escape(&mut self, escape_type: EscapeType, code: &str,
+                     parameters: &[impl Display])
+      -> Result<()>
+  {
+    self.stdout.write_all(escape_type.intro().as_bytes()).await?;
+
+    for (index, parameter) in parameters.iter().enumerate() {
+      self.stdout.write_all(if index < parameters.len() - 1 {
+        format!("{};", parameter)
+      } else {
+        format!("{}{}", parameter, code)
+      }.as_bytes()).await?;
+    }
+
+    Ok(())
+  }
+
+  async fn read_report(&mut self, escape_type: EscapeType)
+      -> Result<(char, Vec<u64>)>
+  {
+    let mut expected = escape_type.intro();
+
+    while expected.len() > 0 {
+      let mut buf = vec![0; 1];
+      self.stdin.read_exact(&mut buf).await?;
+
+      expected = &expected[1..];
+    }
+
+    let mut values = Vec::new();
+
+    let mut number: u64 = 0;
+    let mut in_number = false;
+    let mut after_semicolon = false;
+
+    let mut buf = vec![0; 1];
+    self.stdin.read_exact(&mut buf).await?;
+
+    loop {
+      if let Ok(string) = std::str::from_utf8(&buf)
+         && let Some(c) = string.chars().next()
+      {
+        if c.is_ascii_digit() {
+          if in_number {
+            number = number * 10
+                     + <u32 as Into<u64>>::into(c.to_digit(10).unwrap());
+          } else {
+            number = c.to_digit(10).unwrap().into();
+
+            in_number = true;
+            after_semicolon = false;
+          }
+        } else if c == ';' {
+          if in_number {
+            values.push(number);
+
+            in_number = false;
+            after_semicolon = true;
+          } else {
+            break;
+          }
+        } else if c.is_ascii_alphabetic() {
+          if in_number {
+            values.push(number);
+          } else if after_semicolon {
+            break;
+          }
+
+          return Ok((c, values))
+        } else {
+          break;
+        }
+      } else {
+        break;
+      }
+
+      self.stdin.read_exact(&mut buf).await?;
+    }
+
+    Err(std::io::Error::other("Invalid report from terminal."))
+  }
+
+  // xterm
+  async fn do_start_alternate_screen(&mut self) -> Result<()> {
+    self.do_escape(EscapeType::CSIPrivate, "h", &[1049]).await
+  }
+
+  // xterm
+  async fn do_end_alternate_screen(&mut self) -> Result<()> {
+    self.do_escape(EscapeType::CSIPrivate, "l", &[1049]).await
+  }
+
+  // vt220? vt100?
+  async fn do_cursor_position(&mut self, x: u64, y: u64) -> Result<()> {
+    self.do_escape(EscapeType::CSI, "H", &[y + 1, x + 1]).await
+  }
+
+  // dtterm? xterm
+  async fn do_report_size(&mut self) -> Result<(u64, u64)> {
+    self.do_escape(EscapeType::CSI, "t", &[18]).await?;
+    self.stdout.flush().await?;
+    let (code, values) = self.read_report(EscapeType::CSI).await?;
+    if code == 't' && values.len() == 3 && values[0] == 8 {
+      Ok((values[2], values[1]))
+    } else {
+      Err(std::io::Error::other("Couldn't read terminal size."))
+    }
+  }
+}