Prepare v0.1.5 release

This commit is contained in:
Ivan Potiienko committed 2026-08-31 22:07:34 +02:00
1 parent 3e964f98da
commit 531480d898
9 files changed
+434 -237

No files matched your search

+2 -2
View File
@@ -23,10 +23,10 @@ jobs:
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy -- -D warnings
run: cargo clippy --all-targets -- -D warnings
- name: Run tests
run: cargo test --verbose
run: cargo test --all-targets --verbose
- name: Build
run: cargo build --verbose
+37 -52
View File
@@ -23,10 +23,8 @@ jobs:
include:
- arch: x86_64
target: x86_64-unknown-linux-gnu
deb_arch: amd64
- arch: aarch64
target: aarch64-unknown-linux-gnu
deb_arch: arm64
steps:
- uses: actions/checkout@v4
@@ -55,10 +53,7 @@ jobs:
DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1)
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
ARCH_NAME="${{ matrix.deb_arch }}"
if [ "$ARCH_NAME" = "amd64" ]; then ARCH_NAME="x64"; fi
DEB_NAME="mikusays-debian-${ARCH_NAME}-${VERSION}.deb"
# Store version for later use
DEB_NAME="mikusays-debian-${{ matrix.arch }}-${VERSION}.deb"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "name=$DEB_NAME" >> $GITHUB_OUTPUT
@@ -68,29 +63,18 @@ jobs:
name: deb-package-${{ matrix.arch }}
path: ${{ steps.find-deb.outputs.path }}
- name: Get release upload URL
id: get-release
if: github.event_name == 'release' || github.event.inputs.release_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "upload_url=${{ github.event.release.upload_url }}" >> $GITHUB_OUTPUT
else
RELEASE_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.release_tag }}")
UPLOAD_URL=$(echo "$RELEASE_DATA" | jq -r '.upload_url')
echo "upload_url=$UPLOAD_URL" >> $GITHUB_OUTPUT
fi
- name: Upload to release
if: github.event_name == 'release' || github.event.inputs.release_tag
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
upload_url: ${{ steps.get-release.outputs.upload_url }}
asset_path: ${{ steps.find-deb.outputs.path }}
asset_name: mikusays-debian-${{ matrix.arch }}-${{ steps.find-deb.outputs.version }}.deb
asset_content_type: application/vnd.debian.binary-package
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
run: |
install -m 0644 "${{ steps.find-deb.outputs.path }}" \
"$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}"
gh release upload "$RELEASE_TAG" \
"$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}" \
--repo "$GITHUB_REPOSITORY" \
--clobber
build-rpm:
name: Build RPM Package (${{ matrix.arch }})
@@ -102,10 +86,8 @@ jobs:
include:
- arch: x86_64
target: x86_64-unknown-linux-gnu
rpm_arch: x86_64
- arch: aarch64
target: aarch64-unknown-linux-gnu
rpm_arch: aarch64
steps:
- uses: actions/checkout@v4
@@ -145,10 +127,7 @@ jobs:
RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1)
echo "path=$RPM_PATH" >> $GITHUB_OUTPUT
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
ARCH_NAME="${{ matrix.rpm_arch }}"
if [ "$ARCH_NAME" = "x86_64" ]; then ARCH_NAME="x64"; fi
RPM_NAME="mikusays-redhat-${ARCH_NAME}-${VERSION}.rpm"
# Store version for later use
RPM_NAME="mikusays-redhat-${{ matrix.arch }}-${VERSION}.rpm"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "name=$RPM_NAME" >> $GITHUB_OUTPUT
@@ -158,26 +137,32 @@ jobs:
name: rpm-package-${{ matrix.arch }}
path: ${{ steps.find-rpm.outputs.path }}
- name: Get release upload URL
id: get-release
if: github.event_name == 'release' || github.event.inputs.release_tag
run: |
if [ "${{ github.event_name }}" = "release" ]; then
echo "upload_url=${{ github.event.release.upload_url }}" >> $GITHUB_OUTPUT
else
RELEASE_DATA=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.release_tag }}")
UPLOAD_URL=$(echo "$RELEASE_DATA" | jq -r '.upload_url')
echo "upload_url=$UPLOAD_URL" >> $GITHUB_OUTPUT
fi
- name: Upload to release
if: github.event_name == 'release' || github.event.inputs.release_tag
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
with:
upload_url: ${{ steps.get-release.outputs.upload_url }}
asset_path: ${{ steps.find-rpm.outputs.path }}
asset_name: mikusays-redhat-${{ matrix.arch }}-${{ steps.find-rpm.outputs.version }}.rpm
asset_content_type: application/x-rpm
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
run: |
install -m 0644 "${{ steps.find-rpm.outputs.path }}" \
"$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}"
gh release upload "$RELEASE_TAG" \
"$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}" \
--repo "$GITHUB_REPOSITORY" \
--clobber
notify-package-repository:
name: Notify package repository
runs-on: ubuntu-latest
needs: [build-deb, build-rpm]
if: github.event_name == 'release' || github.event.inputs.release_tag
permissions:
contents: read
steps:
- name: Dispatch exact release tag
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
run: |
gh api --method POST repos/xxanqw/apt-repository/dispatches \
--raw-field event_type=new-release \
--field "client_payload[tag_name]=$RELEASE_TAG"
+58 -21
View File
@@ -113,10 +113,21 @@ jobs:
- name: Prepare artifact
run: |
mkdir -p artifacts
mkdir -p artifacts staging
version="${{ needs.extract-version.outputs.version }}"
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-linux-${{ matrix.arch }}-$version
chmod +x artifacts/mikusays-linux-${{ matrix.arch }}-$version
stem="mikusays-linux-${{ matrix.arch }}-$version"
cp target/${{ matrix.target }}/release/mikusays "staging/$stem"
chmod 755 "staging/$stem"
tar -C staging -czf "artifacts/$stem.tar.gz" "$stem"
- name: Verify artifact
run: |
version="${{ needs.extract-version.outputs.version }}"
stem="mikusays-linux-${{ matrix.arch }}-$version"
extract_dir="$(mktemp -d)"
tar -tzf "artifacts/$stem.tar.gz" | grep -Fx "$stem"
tar -xzf "artifacts/$stem.tar.gz" -C "$extract_dir"
test -x "$extract_dir/$stem"
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -150,10 +161,21 @@ jobs:
- name: Prepare artifact
run: |
mkdir -p artifacts
mkdir -p artifacts staging
version="${{ needs.extract-version.outputs.version }}"
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-macos-${{ matrix.arch }}-$version
chmod +x artifacts/mikusays-macos-${{ matrix.arch }}-$version
stem="mikusays-macos-${{ matrix.arch }}-$version"
cp target/${{ matrix.target }}/release/mikusays "staging/$stem"
chmod 755 "staging/$stem"
tar -C staging -czf "artifacts/$stem.tar.gz" "$stem"
- name: Verify artifact
run: |
version="${{ needs.extract-version.outputs.version }}"
stem="mikusays-macos-${{ matrix.arch }}-$version"
extract_dir="$(mktemp -d)"
tar -tzf "artifacts/$stem.tar.gz" | grep -Fx "$stem"
tar -xzf "artifacts/$stem.tar.gz" -C "$extract_dir"
test -x "$extract_dir/$stem"
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -179,7 +201,7 @@ jobs:
run: ls -R artifacts
- name: Create Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
with:
files: artifacts/mikusays-*
draft: false
@@ -195,33 +217,48 @@ jobs:
### Windows Installer
- **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe`
### Linux (binary)
- **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}`
- **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}`
### Linux (tar.gz)
- **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
- **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
### Debian Package
- **x64 Debian Package**: `mikusays-debian-x64-${{ needs.extract-version.outputs.version }}.deb`
- **ARM64 Debian Package**: `mikusays-debian-arm64-${{ needs.extract-version.outputs.version }}.deb`
- **x64 Debian Package**: `mikusays-debian-x86_64-${{ needs.extract-version.outputs.version }}.deb`
- **ARM64 Debian Package**: `mikusays-debian-aarch64-${{ needs.extract-version.outputs.version }}.deb`
### RedHat (RPM) Package
- **x64 RPM Package**: `mikusays-redhat-x64-${{ needs.extract-version.outputs.version }}.rpm`
- **ARM64 RPM Package**: `mikusays-redhat-arm64-${{ needs.extract-version.outputs.version }}.rpm`
- **x64 RPM Package**: `mikusays-redhat-x86_64-${{ needs.extract-version.outputs.version }}.rpm`
- **ARM64 RPM Package**: `mikusays-redhat-aarch64-${{ needs.extract-version.outputs.version }}.rpm`
### Repository for Debian / Ubuntu / Fedora / RHEL / CentOS
Information on how to add it available here https://apt.xxanqw.pp.ua
### macOS
- **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}`
- **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}`
### macOS (tar.gz)
- **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
- **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
## Usage
```bash
# Download the appropriate binary for your platform
# On Unix-like systems, make it executable:
chmod +x mikusays-*
# Download and extract the appropriate archive on Linux/macOS:
archive="mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz"
tar -xzf "$archive"
# Run the program:
./mikusays-* "Hello, World!"
"./${archive%.tar.gz}" "Hello, World!"
# If Gatekeeper blocks an unsigned macOS binary:
xattr -d com.apple.quarantine "./${archive%.tar.gz}"
```
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Notify package managers
env:
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
RELEASE_TAG: v${{ needs.extract-version.outputs.version }}
run: |
gh api --method POST repos/xxanqw/scoop-bucket/dispatches \
--raw-field event_type=new-release \
--field "client_payload[tag_name]=$RELEASE_TAG"
gh api --method POST repos/xxanqw/homebrew-mikusays/dispatches \
--raw-field event_type=new-release \
--field "client_payload[tag_name]=$RELEASE_TAG"
Generated
+1 -1
View File
@@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
[[package]]
name = "mikusays"
version = "0.1.4-2"
version = "0.1.5"
dependencies = [
"clap",
"crossterm",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mikusays"
version = "0.1.4-2"
version = "0.1.5"
edition = "2024"
description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles."
license = "MIT"
+28 -2
View File
@@ -74,8 +74,23 @@ brew install mikusays
- **Windows**: `mikusays-windows-*.exe`
- Small note for Windows that application depends on `vcredist2022`
(installing with scoop and installer resolve this dependency automatically)
- **Linux**: `mikusays-linux-*`
- **macOS**: `mikusays-macos-*`
- **Linux**: `mikusays-linux-*.tar.gz`
- **macOS**: `mikusays-macos-*.tar.gz`
Linux and macOS archives preserve the executable permission. Extract and run one with:
```bash
archive="mikusays-linux-x64-VERSION.tar.gz"
tar -xzf "$archive"
"./${archive%.tar.gz}" "Hello, World!"
```
The macOS binaries are currently unsigned. If Gatekeeper quarantines an extracted binary,
remove the quarantine attribute before running it:
```bash
xattr -d com.apple.quarantine ./mikusays-macos-arm64-VERSION
```
Or build from source:
```bash
@@ -84,6 +99,17 @@ cargo build --release
## Development
### Release automation
Tagged releases build the platform binaries first, then trigger the Scoop bucket.
After all DEB and RPM assets have uploaded, the package workflow triggers the APT/RPM
repository with the exact tag. The `RELEASE_TOKEN` Actions secret must be able to create
repository dispatch events in `xxanqw/scoop-bucket`, `xxanqw/homebrew-mikusays`, and
`xxanqw/apt-repository`.
The distribution repositories keep scheduled and manual workflows as recovery paths if
an event dispatch fails.
1. Fork repository
2. Clone your fork
3. Implement your changes
+8 -18
View File
@@ -301,14 +301,8 @@ fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) {
let s = s as f32 / 100.0;
let l = l as f32 / 100.0;
let r;
let g;
let b;
if s == 0.0 {
r = l;
g = l;
b = l;
let (r, g, b) = if s == 0.0 {
(l, l, l)
} else {
let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
let mut t = t;
@@ -337,10 +331,12 @@ fn hsl_to_rgb(h: u16, s: u8, l: u8) -> (u8, u8, u8) {
};
let p = 2.0 * l - q;
r = hue_to_rgb(p, q, h + 1.0 / 3.0);
g = hue_to_rgb(p, q, h);
b = hue_to_rgb(p, q, h - 1.0 / 3.0);
}
(
hue_to_rgb(p, q, h + 1.0 / 3.0),
hue_to_rgb(p, q, h),
hue_to_rgb(p, q, h - 1.0 / 3.0),
)
};
(
(r * 255.0).round() as u8,
@@ -493,12 +489,6 @@ pub fn detect_windows_shell() -> Option<String> {
None
}
/// Convert Color to a crossterm Color (helper for use across platforms)
pub fn to_crossterm_color(color: &Color) -> crossterm::style::Color {
let (r, g, b) = color.to_rgb();
crossterm::style::Color::Rgb { r, g, b }
}
/// Detect the parent shell name on Windows, using the sysinfo crate.
pub fn detect_color_mode() -> ColorMode {
if std::env::var("NO_COLOR").is_ok() {
+218 -133
View File
@@ -1,26 +1,19 @@
use clap::{Parser, ValueEnum};
use clap::error::ErrorKind;
use clap::{CommandFactory, Parser};
use crossterm::QueueableCommand;
use crossterm::style::{Print, ResetColor, SetForegroundColor};
use crossterm::style::{Print, ResetColor};
use crossterm::terminal::size;
use std::error::Error;
use std::fmt;
use std::io::{Write, stdout};
use std::str::FromStr;
use unicode_width::UnicodeWidthStr;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
mod color;
mod mikuart;
use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv};
use mikuart::get_miku_art;
#[derive(Debug, Clone, ValueEnum)]
enum ColorFormat {
Hex,
Rgb,
Hsl,
Ansi,
Named,
}
#[derive(Parser)]
#[command(version, about, long_about = None)]
pub struct Args {
@@ -28,7 +21,7 @@ pub struct Args {
pub text: Option<String>,
/// Style of the Miku art. A random one is chosen if not specified
#[arg(short, long)]
#[arg(short, long, value_parser = clap::value_parser!(i32).range(0..))]
pub style: Option<i32>,
/// List all available art styles with their indices
@@ -36,19 +29,19 @@ pub struct Args {
pub list: bool,
/// Apply a smooth rainbow gradient across the ASCII art
#[arg(long)]
#[arg(long, conflicts_with_all = ["color", "gradient"])]
pub rainbow: bool,
/// Saturation level for rainbow gradient (0-100)
#[arg(long, default_value_t = 100)]
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u8).range(0..=100))]
pub saturation: u8,
/// Brightness level for rainbow gradient (0-100)
#[arg(long, default_value_t = 50)]
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u8).range(0..=100))]
pub brightness: u8,
/// Override with a single solid color
#[arg(long, value_name = "COLOR")]
#[arg(long, value_name = "COLOR", conflicts_with = "gradient")]
pub color: Option<String>,
/// Define a custom two-color gradient (e.g., --gradient red:blue)
@@ -76,7 +69,21 @@ pub struct ColorConfig {
pub debug: bool,
}
pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
#[derive(Debug, PartialEq)]
pub struct ColorConfigError(String);
impl fmt::Display for ColorConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Error for ColorConfigError {}
const COLOR_FORMAT_HELP: &str =
"expected a named color, #RRGGBB, #RGB, R,G,B, hsl(H,S%,L%), or ANSI value 0-255";
pub fn parse_color_args(args: &Args) -> Result<ColorConfig, ColorConfigError> {
// Detect color mode
let mut color_mode = detect_color_mode();
@@ -86,83 +93,65 @@ pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
}
// Parse single color if provided
let single_color = if let Some(color_str) = args.color.as_deref() {
match Color::from_str(color_str) {
Ok(color) => Some(color),
Err(e) => {
eprintln!("Error parsing color '{}': {}", color_str, e);
eprintln!("Valid color formats:");
eprintln!(" - Named colors: red, blue, green, etc.");
eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
std::process::exit(1);
}
}
} else {
None
};
let single_color = args
.color
.as_deref()
.map(|color_str| {
Color::from_str(color_str).map_err(|error| {
ColorConfigError(format!(
"invalid color '{color_str}': {error}; {COLOR_FORMAT_HELP}"
))
})
})
.transpose()?;
// Parse gradient if provided
let gradient = if let Some(gradient_str) = args.gradient.as_deref() {
let parts: Vec<&str> = gradient_str.split(':').collect();
if parts.len() != 2 {
eprintln!("Error: Gradient format should be START:END (e.g., --gradient red:blue)");
std::process::exit(1);
let (start_str, end_str) = gradient_str.split_once(':').ok_or_else(|| {
ColorConfigError(
"invalid gradient: expected START:END (for example, red:blue)".to_string(),
)
})?;
if start_str.is_empty() || end_str.is_empty() || end_str.contains(':') {
return Err(ColorConfigError(
"invalid gradient: expected exactly two colors as START:END".to_string(),
));
}
let start = match Color::from_str(parts[0]) {
Ok(color) => color,
Err(e) => {
eprintln!("Error parsing gradient start color '{}': {}", parts[0], e);
eprintln!("Valid color formats:");
eprintln!(" - Named colors: red, blue, green, etc.");
eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
std::process::exit(1);
}
};
let end = match Color::from_str(parts[1]) {
Ok(color) => color,
Err(e) => {
eprintln!("Error parsing gradient end color '{}': {}", parts[1], e);
eprintln!("Valid color formats:");
eprintln!(" - Named colors: red, blue, green, etc.");
eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
eprintln!(" - HSL: hsl(H,S%,L%) (e.g., hsl(180,100%,50%))");
eprintln!(" - ANSI: 0-255 (e.g., 93 for bright yellow)");
std::process::exit(1);
}
};
let start = Color::from_str(start_str).map_err(|error| {
ColorConfigError(format!(
"invalid gradient start '{start_str}': {error}; {COLOR_FORMAT_HELP}"
))
})?;
let end = Color::from_str(end_str).map_err(|error| {
ColorConfigError(format!(
"invalid gradient end '{end_str}': {error}; {COLOR_FORMAT_HELP}"
))
})?;
Some((start, end))
} else {
None
};
// Validate saturation and brightness
let saturation = args.saturation.clamp(0, 100);
let brightness = args.brightness.clamp(0, 100);
// Check for conflicting options
// Keep this validation for direct callers as well as Clap-parsed CLI input.
if args.rainbow && single_color.is_some() {
eprintln!("Error: Cannot use --rainbow and --color together");
std::process::exit(1);
return Err(ColorConfigError(
"--rainbow cannot be used with --color".to_string(),
));
}
if args.rainbow && gradient.is_some() {
eprintln!("Error: Cannot use --rainbow and --gradient together");
std::process::exit(1);
return Err(ColorConfigError(
"--rainbow cannot be used with --gradient".to_string(),
));
}
if single_color.is_some() && gradient.is_some() {
eprintln!("Error: Cannot use --color and --gradient together");
std::process::exit(1);
return Err(ColorConfigError(
"--color cannot be used with --gradient".to_string(),
));
}
// Determine if ANSI is enabled (Windows toggle helper) — don't enable if --no-color
@@ -174,8 +163,8 @@ pub fn parse_color_args(args: &Args) -> Result<ColorConfig, Box<dyn Error>> {
Ok(ColorConfig {
mode: color_mode,
rainbow: args.rainbow,
saturation,
brightness,
saturation: args.saturation,
brightness: args.brightness,
single_color,
gradient,
color_cache: Vec::new(),
@@ -245,24 +234,47 @@ pub fn get_speech_bubble_lines(text: &str) -> Vec<String> {
}
pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
if max_width == 0 {
return if text.is_empty() {
Vec::new()
} else {
vec![text.to_string()]
};
}
let mut lines = Vec::new();
let words: Vec<&str> = text.split_whitespace().collect();
let mut current_line = String::new();
for word in words {
for word in text.split_whitespace() {
let word_width = word.width();
let current_width = current_line.width();
let separator_width = usize::from(!current_line.is_empty());
if current_width + word_width < max_width {
if word_width <= max_width && current_width + separator_width + word_width <= max_width {
if !current_line.is_empty() {
current_line.push(' ');
}
current_line.push_str(word);
} else {
if !current_line.is_empty() {
lines.push(current_line);
continue;
}
if !current_line.is_empty() {
lines.push(std::mem::take(&mut current_line));
}
if word_width <= max_width {
current_line.push_str(word);
continue;
}
let chunks = split_word_by_width(word, max_width);
let last_index = chunks.len().saturating_sub(1);
for (index, chunk) in chunks.into_iter().enumerate() {
if index == last_index && chunk.width() < max_width {
current_line = chunk;
} else {
lines.push(chunk);
}
current_line = word.to_string();
}
}
@@ -273,6 +285,28 @@ pub fn wrap_text(text: &str, max_width: usize) -> Vec<String> {
lines
}
fn split_word_by_width(word: &str, max_width: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut chunk = String::new();
let mut chunk_width = 0;
for character in word.chars() {
let character_width = character.width().unwrap_or(0);
if !chunk.is_empty() && chunk_width + character_width > max_width {
chunks.push(std::mem::take(&mut chunk));
chunk_width = 0;
}
chunk.push(character);
chunk_width += character_width;
}
if !chunk.is_empty() {
chunks.push(chunk);
}
chunks
}
pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) {
color_config.color_cache.clear();
@@ -309,7 +343,11 @@ pub fn generate_gradient_cache(
// Generate smooth gradient across all characters
for i in 0..total_chars {
let t = i as f32 / total_chars as f32;
let t = if total_chars == 1 {
0.0
} else {
i as f32 / (total_chars - 1) as f32
};
let color = lerp_color_hsv(start, end, t);
color_config.color_cache.push(color);
}
@@ -359,8 +397,15 @@ fn draw_miku_says(
let speech_bubble_lines = get_speech_bubble_lines(text);
let miku_art = get_miku_art(Some(style), None);
// Calculate total character count for color distribution
let total_chars: usize = miku_art.iter().map(|line| line.chars().count()).sum();
// Distribute colors across characters that are actually colored.
let total_chars: usize = miku_art
.iter()
.map(|line| {
line.chars()
.filter(|character| !character.is_whitespace())
.count()
})
.sum();
// Generate color cache if needed
if color_config.rainbow {
@@ -443,36 +488,36 @@ fn draw_miku_says(
if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() {
// Use crossterm queueing to color characters so it's platform-agnostic
let mut stdout = stdout();
stdout.queue(Print(horizontal_padding.clone())).ok();
stdout.queue(Print(horizontal_padding.clone()))?;
for c in line.chars() {
if c.is_whitespace() {
stdout.queue(Print(c)).ok();
stdout.queue(Print(c))?;
} else if color_index < color_config.color_cache.len() {
let color = &color_config.color_cache[color_index];
let ct_color = color::to_crossterm_color(color);
stdout.queue(SetForegroundColor(ct_color)).ok();
stdout.queue(Print(c)).ok();
stdout.queue(ResetColor).ok();
stdout.queue(Print(color.to_ansi_fg(color_config.mode)))?;
stdout.queue(Print(c))?;
stdout.queue(ResetColor)?;
color_index += 1;
} else {
stdout.queue(Print(c)).ok();
stdout.queue(Print(c))?;
}
}
stdout.queue(Print("\n")).ok();
stdout.flush().ok();
stdout.queue(Print("\n"))?;
stdout.flush()?;
continue;
} else if let Some(single_color) = &color_config.single_color {
} else if color_config.mode != ColorMode::NoColor
&& let Some(single_color) = &color_config.single_color
{
// Apply single solid color using crossterm
let mut stdout = stdout();
let ct_color = color::to_crossterm_color(single_color);
stdout.queue(Print(horizontal_padding.clone())).ok();
stdout.queue(SetForegroundColor(ct_color)).ok();
stdout.queue(Print(line)).ok();
stdout.queue(ResetColor).ok();
stdout.queue(Print("\n")).ok();
stdout.flush().ok();
stdout.queue(Print(horizontal_padding.clone()))?;
stdout.queue(Print(single_color.to_ansi_fg(color_config.mode)))?;
stdout.queue(Print(line))?;
stdout.queue(ResetColor)?;
stdout.queue(Print("\n"))?;
stdout.flush()?;
} else {
// No coloring
println!("{}{}", horizontal_padding, line);
@@ -500,10 +545,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
std::process::exit(1);
});
let style = args.style.unwrap_or(-1); // Default to -1 to select a random style
let styles_count = get_miku_art(None, Some(true)).len();
let style = match args.style {
Some(style) if style as usize >= styles_count => Args::command()
.error(
ErrorKind::InvalidValue,
format!(
"style {style} does not exist; choose a value between 0 and {}",
styles_count - 1
),
)
.exit(),
Some(style) => style,
None => -1,
};
// Parse color-related arguments
let mut color_config = parse_color_args(&args)?;
let mut color_config = parse_color_args(&args).unwrap_or_else(|error| {
Args::command()
.error(ErrorKind::ValueValidation, error.to_string())
.exit()
});
if color_config.debug {
println!("Detected color mode: {:?}", color_config.mode);
@@ -518,25 +580,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
// If color debug is requested, print information and exit
if args.color_debug {
eprintln!(
"Color Debug: mode={:?}, ansi_enabled={}, rainbow={}, saturation={}, brightness={}",
color_config.mode,
color_config.ansi_enabled,
color_config.rainbow,
color_config.saturation,
color_config.brightness
);
if let Some(single) = &color_config.single_color {
eprintln!("Single color: {:?}", single);
}
if let Some((start, end)) = &color_config.gradient {
eprintln!("Gradient: {:?} -> {:?}", start, end);
}
return Ok(());
}
draw_miku_says(&text, style, &mut color_config)?;
Ok(())
@@ -566,7 +609,8 @@ mod tests {
fn test_wrap_text_long_word() {
let text = "ThisIsAVeryLongWordThatExceedsMaxWidth";
let wrapped = wrap_text(text, 10);
assert_eq!(wrapped, vec!["ThisIsAVeryLongWordThatExceedsMaxWidth"]);
assert_eq!(wrapped.concat(), text);
assert!(wrapped.iter().all(|line| line.width() <= 10));
}
#[test]
@@ -667,7 +711,8 @@ mod tests {
// Test with single very long word
let text = "Supercalifragilisticexpialidocious";
let wrapped = wrap_text(text, 10);
assert_eq!(wrapped, vec!["Supercalifragilisticexpialidocious"]);
assert_eq!(wrapped.concat(), text);
assert!(wrapped.iter().all(|line| line.width() <= 10));
// Test with exact width match
let text = "Hello world";
@@ -690,7 +735,20 @@ mod tests {
let text = "Supercalifragilisticexpialidocious";
let bubble = get_speech_bubble_lines(text);
assert!(bubble.len() >= 5);
assert!(bubble[0].len() >= 24); // Should accommodate the long word
let border_width = bubble[0].width();
assert!(
bubble[..bubble.len() - 2]
.iter()
.all(|line| line.width() == border_width)
);
}
#[test]
fn test_wrap_text_splits_long_unicode_word_by_display_width() {
let wrapped = wrap_text("初音ミク初音ミク初音ミク", 8);
assert!(wrapped.len() > 1);
assert!(wrapped.iter().all(|line| line.width() <= 8));
}
#[test]
@@ -793,11 +851,38 @@ mod tests {
generate_gradient_cache(&mut config, &start, &end, 10);
assert_eq!(config.color_cache.len(), 10);
// Check that first color is close to start
let first_color = &config.color_cache[0];
// Check that last color is close to end
let last_color = &config.color_cache[9];
assert_ne!(first_color, last_color);
assert_eq!(first_color.to_rgb(), start.to_rgb());
assert_eq!(last_color.to_rgb(), end.to_rgb());
}
#[test]
fn test_parse_color_args_reports_conflicts_without_exiting() {
let args = Args {
text: Some("test".to_string()),
style: None,
list: false,
rainbow: true,
saturation: 100,
brightness: 50,
color: Some("red".to_string()),
gradient: None,
no_color: false,
color_debug: false,
};
let error = parse_color_args(&args).unwrap_err();
assert_eq!(error.to_string(), "--rainbow cannot be used with --color");
}
#[test]
fn test_color_conversion_respects_terminal_mode() {
let red = Color::Rgb(255, 0, 0);
assert_eq!(red.to_ansi_fg(ColorMode::Truecolor), "\x1b[38;2;255;0;0m");
assert_eq!(red.to_ansi_fg(ColorMode::Ansi256), "\x1b[38;5;196m");
assert_eq!(red.to_ansi_fg(ColorMode::Ansi16), "\x1b[31m");
assert_eq!(red.to_ansi_fg(ColorMode::NoColor), "");
}
#[test]
+81 -7
View File
@@ -1,9 +1,13 @@
use std::process::Command;
use std::str;
fn command() -> Command {
Command::new(env!("CARGO_BIN_EXE_mikusays"))
}
#[test]
fn test_cli_list_styles() {
let output = Command::new("target/debug/mikusays")
let output = command()
.arg("--list")
.output()
.expect("Failed to execute command");
@@ -19,9 +23,7 @@ fn test_cli_list_styles() {
#[test]
fn test_cli_error_no_text() {
let output = Command::new("target/debug/mikusays")
.output()
.expect("Failed to execute command");
let output = command().output().expect("Failed to execute command");
assert!(!output.status.success());
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
@@ -30,7 +32,7 @@ fn test_cli_error_no_text() {
#[test]
fn test_cli_error_conflicting_args() {
let output = Command::new("target/debug/mikusays")
let output = command()
.arg("Test")
.arg("--rainbow")
.arg("--color")
@@ -40,16 +42,88 @@ fn test_cli_error_conflicting_args() {
assert!(!output.status.success());
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
assert!(stderr.contains("Cannot use --rainbow and --color together"));
assert!(stderr.contains("cannot be used with"));
}
#[test]
fn test_cli_basic_functionality() {
// Test that the binary runs and produces some output
let output = Command::new("target/debug/mikusays")
let output = command()
.arg("--list")
.output()
.expect("Failed to execute command");
assert!(output.status.success());
}
#[test]
fn test_cli_no_color_overrides_solid_color() {
let output = command()
.args(["test", "--style", "0", "--color", "red", "--no-color"])
.env("TERM", "xterm-256color")
.output()
.expect("Failed to execute command");
assert!(output.status.success());
assert!(!output.stdout.contains(&0x1b));
}
#[test]
fn test_cli_no_color_environment_overrides_gradient() {
let output = command()
.args(["test", "--style", "0", "--gradient", "red:blue"])
.env("NO_COLOR", "1")
.env("TERM", "xterm-256color")
.output()
.expect("Failed to execute command");
assert!(output.status.success());
assert!(!output.stdout.contains(&0x1b));
}
#[test]
fn test_cli_rejects_out_of_range_style_without_panicking() {
let output = command()
.args(["test", "--style", "999"])
.output()
.expect("Failed to execute command");
assert_eq!(output.status.code(), Some(2));
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
assert!(stderr.contains("style 999 does not exist"));
assert!(!stderr.contains("panicked"));
}
#[test]
fn test_cli_rejects_negative_style() {
let output = command()
.args(["test", "--style", "-1"])
.output()
.expect("Failed to execute command");
assert_eq!(output.status.code(), Some(2));
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
assert!(!stderr.contains("panicked"));
}
#[test]
fn test_cli_rejects_out_of_range_saturation() {
let output = command()
.args(["test", "--rainbow", "--saturation", "101"])
.output()
.expect("Failed to execute command");
assert_eq!(output.status.code(), Some(2));
}
#[test]
fn test_cli_rejects_invalid_color() {
let output = command()
.args(["test", "--color", "not-a-color"])
.output()
.expect("Failed to execute command");
assert_eq!(output.status.code(), Some(2));
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
assert!(stderr.contains("invalid color 'not-a-color'"));
}