Prepare v0.1.5 release
This commit is contained in:
1 parent
3e964f98da
commit
531480d898
9 files changed
+434
-237
No files matched your search
@@ -23,10 +23,10 @@ jobs:
|
|||||||
run: cargo fmt -- --check
|
run: cargo fmt -- --check
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy
|
||||||
run: cargo clippy -- -D warnings
|
run: cargo clippy --all-targets -- -D warnings
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: cargo test --verbose
|
run: cargo test --all-targets --verbose
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: cargo build --verbose
|
run: cargo build --verbose
|
||||||
@@ -23,10 +23,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- arch: x86_64
|
- arch: x86_64
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
deb_arch: amd64
|
|
||||||
- arch: aarch64
|
- arch: aarch64
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
deb_arch: arm64
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -55,10 +53,7 @@ jobs:
|
|||||||
DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1)
|
DEB_PATH=$(find target/${{ matrix.target }}/debian -name "*.deb" | head -n 1)
|
||||||
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
|
echo "path=$DEB_PATH" >> $GITHUB_OUTPUT
|
||||||
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
|
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
|
||||||
ARCH_NAME="${{ matrix.deb_arch }}"
|
DEB_NAME="mikusays-debian-${{ matrix.arch }}-${VERSION}.deb"
|
||||||
if [ "$ARCH_NAME" = "amd64" ]; then ARCH_NAME="x64"; fi
|
|
||||||
DEB_NAME="mikusays-debian-${ARCH_NAME}-${VERSION}.deb"
|
|
||||||
# Store version for later use
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "name=$DEB_NAME" >> $GITHUB_OUTPUT
|
echo "name=$DEB_NAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -68,29 +63,18 @@ jobs:
|
|||||||
name: deb-package-${{ matrix.arch }}
|
name: deb-package-${{ matrix.arch }}
|
||||||
path: ${{ steps.find-deb.outputs.path }}
|
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
|
- name: Upload to release
|
||||||
if: github.event_name == 'release' || github.event.inputs.release_tag
|
if: github.event_name == 'release' || github.event.inputs.release_tag
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
with:
|
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
|
||||||
upload_url: ${{ steps.get-release.outputs.upload_url }}
|
run: |
|
||||||
asset_path: ${{ steps.find-deb.outputs.path }}
|
install -m 0644 "${{ steps.find-deb.outputs.path }}" \
|
||||||
asset_name: mikusays-debian-${{ matrix.arch }}-${{ steps.find-deb.outputs.version }}.deb
|
"$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}"
|
||||||
asset_content_type: application/vnd.debian.binary-package
|
gh release upload "$RELEASE_TAG" \
|
||||||
|
"$RUNNER_TEMP/${{ steps.find-deb.outputs.name }}" \
|
||||||
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
|
--clobber
|
||||||
|
|
||||||
build-rpm:
|
build-rpm:
|
||||||
name: Build RPM Package (${{ matrix.arch }})
|
name: Build RPM Package (${{ matrix.arch }})
|
||||||
@@ -102,10 +86,8 @@ jobs:
|
|||||||
include:
|
include:
|
||||||
- arch: x86_64
|
- arch: x86_64
|
||||||
target: x86_64-unknown-linux-gnu
|
target: x86_64-unknown-linux-gnu
|
||||||
rpm_arch: x86_64
|
|
||||||
- arch: aarch64
|
- arch: aarch64
|
||||||
target: aarch64-unknown-linux-gnu
|
target: aarch64-unknown-linux-gnu
|
||||||
rpm_arch: aarch64
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
@@ -145,10 +127,7 @@ jobs:
|
|||||||
RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1)
|
RPM_PATH=$(find target/${{ matrix.target }}/generate-rpm -name "*.rpm" | head -n 1)
|
||||||
echo "path=$RPM_PATH" >> $GITHUB_OUTPUT
|
echo "path=$RPM_PATH" >> $GITHUB_OUTPUT
|
||||||
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
|
VERSION=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].version')
|
||||||
ARCH_NAME="${{ matrix.rpm_arch }}"
|
RPM_NAME="mikusays-redhat-${{ matrix.arch }}-${VERSION}.rpm"
|
||||||
if [ "$ARCH_NAME" = "x86_64" ]; then ARCH_NAME="x64"; fi
|
|
||||||
RPM_NAME="mikusays-redhat-${ARCH_NAME}-${VERSION}.rpm"
|
|
||||||
# Store version for later use
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "name=$RPM_NAME" >> $GITHUB_OUTPUT
|
echo "name=$RPM_NAME" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -158,26 +137,32 @@ jobs:
|
|||||||
name: rpm-package-${{ matrix.arch }}
|
name: rpm-package-${{ matrix.arch }}
|
||||||
path: ${{ steps.find-rpm.outputs.path }}
|
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
|
- name: Upload to release
|
||||||
if: github.event_name == 'release' || github.event.inputs.release_tag
|
if: github.event_name == 'release' || github.event.inputs.release_tag
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
with:
|
RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.release_tag }}
|
||||||
upload_url: ${{ steps.get-release.outputs.upload_url }}
|
run: |
|
||||||
asset_path: ${{ steps.find-rpm.outputs.path }}
|
install -m 0644 "${{ steps.find-rpm.outputs.path }}" \
|
||||||
asset_name: mikusays-redhat-${{ matrix.arch }}-${{ steps.find-rpm.outputs.version }}.rpm
|
"$RUNNER_TEMP/${{ steps.find-rpm.outputs.name }}"
|
||||||
asset_content_type: application/x-rpm
|
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"
|
||||||
@@ -113,10 +113,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare artifact
|
- name: Prepare artifact
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts staging
|
||||||
version="${{ needs.extract-version.outputs.version }}"
|
version="${{ needs.extract-version.outputs.version }}"
|
||||||
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-linux-${{ matrix.arch }}-$version
|
stem="mikusays-linux-${{ matrix.arch }}-$version"
|
||||||
chmod +x artifacts/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
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -150,10 +161,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Prepare artifact
|
- name: Prepare artifact
|
||||||
run: |
|
run: |
|
||||||
mkdir -p artifacts
|
mkdir -p artifacts staging
|
||||||
version="${{ needs.extract-version.outputs.version }}"
|
version="${{ needs.extract-version.outputs.version }}"
|
||||||
cp target/${{ matrix.target }}/release/mikusays artifacts/mikusays-macos-${{ matrix.arch }}-$version
|
stem="mikusays-macos-${{ matrix.arch }}-$version"
|
||||||
chmod +x artifacts/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
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -179,7 +201,7 @@ jobs:
|
|||||||
run: ls -R artifacts
|
run: ls -R artifacts
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v1
|
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||||
with:
|
with:
|
||||||
files: artifacts/mikusays-*
|
files: artifacts/mikusays-*
|
||||||
draft: false
|
draft: false
|
||||||
@@ -195,33 +217,48 @@ jobs:
|
|||||||
### Windows Installer
|
### Windows Installer
|
||||||
- **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe`
|
- **Any**: `mikusays-windows-inst-any-${{ needs.extract-version.outputs.version }}.exe`
|
||||||
|
|
||||||
### Linux (binary)
|
### Linux (tar.gz)
|
||||||
- **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}`
|
- **x64**: `mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
|
||||||
- **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}`
|
- **ARM64**: `mikusays-linux-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
|
||||||
|
|
||||||
### Debian Package
|
### Debian Package
|
||||||
- **x64 Debian Package**: `mikusays-debian-x64-${{ needs.extract-version.outputs.version }}.deb`
|
- **x64 Debian Package**: `mikusays-debian-x86_64-${{ needs.extract-version.outputs.version }}.deb`
|
||||||
- **ARM64 Debian Package**: `mikusays-debian-arm64-${{ needs.extract-version.outputs.version }}.deb`
|
- **ARM64 Debian Package**: `mikusays-debian-aarch64-${{ needs.extract-version.outputs.version }}.deb`
|
||||||
|
|
||||||
### RedHat (RPM) Package
|
### RedHat (RPM) Package
|
||||||
- **x64 RPM Package**: `mikusays-redhat-x64-${{ needs.extract-version.outputs.version }}.rpm`
|
- **x64 RPM Package**: `mikusays-redhat-x86_64-${{ needs.extract-version.outputs.version }}.rpm`
|
||||||
- **ARM64 RPM Package**: `mikusays-redhat-arm64-${{ 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
|
### Repository for Debian / Ubuntu / Fedora / RHEL / CentOS
|
||||||
Information on how to add it available here https://apt.xxanqw.pp.ua
|
Information on how to add it available here https://apt.xxanqw.pp.ua
|
||||||
|
|
||||||
### macOS
|
### macOS (tar.gz)
|
||||||
- **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}`
|
- **Intel (x64)**: `mikusays-macos-x64-${{ needs.extract-version.outputs.version }}.tar.gz`
|
||||||
- **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}`
|
- **Apple Silicon (ARM64)**: `mikusays-macos-arm64-${{ needs.extract-version.outputs.version }}.tar.gz`
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
```bash
|
```bash
|
||||||
# Download the appropriate binary for your platform
|
# Download and extract the appropriate archive on Linux/macOS:
|
||||||
# On Unix-like systems, make it executable:
|
archive="mikusays-linux-x64-${{ needs.extract-version.outputs.version }}.tar.gz"
|
||||||
chmod +x mikusays-*
|
tar -xzf "$archive"
|
||||||
|
|
||||||
# Run the program:
|
# 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:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
@@ -252,7 +252,7 @@ checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mikusays"
|
name = "mikusays"
|
||||||
version = "0.1.4-2"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mikusays"
|
name = "mikusays"
|
||||||
version = "0.1.4-2"
|
version = "0.1.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles."
|
description = "A \"cowsay\" clone with Hatsune Miku ASCII art and speech bubbles."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -74,8 +74,23 @@ brew install mikusays
|
|||||||
- **Windows**: `mikusays-windows-*.exe`
|
- **Windows**: `mikusays-windows-*.exe`
|
||||||
- Small note for Windows that application depends on `vcredist2022`
|
- Small note for Windows that application depends on `vcredist2022`
|
||||||
(installing with scoop and installer resolve this dependency automatically)
|
(installing with scoop and installer resolve this dependency automatically)
|
||||||
- **Linux**: `mikusays-linux-*`
|
- **Linux**: `mikusays-linux-*.tar.gz`
|
||||||
- **macOS**: `mikusays-macos-*`
|
- **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:
|
Or build from source:
|
||||||
```bash
|
```bash
|
||||||
@@ -84,6 +99,17 @@ cargo build --release
|
|||||||
|
|
||||||
## Development
|
## 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
|
1. Fork repository
|
||||||
2. Clone your fork
|
2. Clone your fork
|
||||||
3. Implement your changes
|
3. Implement your changes
|
||||||
|
|||||||
+8
-18
@@ -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 s = s as f32 / 100.0;
|
||||||
let l = l as f32 / 100.0;
|
let l = l as f32 / 100.0;
|
||||||
|
|
||||||
let r;
|
let (r, g, b) = if s == 0.0 {
|
||||||
let g;
|
(l, l, l)
|
||||||
let b;
|
|
||||||
|
|
||||||
if s == 0.0 {
|
|
||||||
r = l;
|
|
||||||
g = l;
|
|
||||||
b = l;
|
|
||||||
} else {
|
} else {
|
||||||
let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
|
let hue_to_rgb = |p: f32, q: f32, t: f32| -> f32 {
|
||||||
let mut t = t;
|
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;
|
let p = 2.0 * l - q;
|
||||||
|
|
||||||
r = hue_to_rgb(p, q, h + 1.0 / 3.0);
|
(
|
||||||
g = hue_to_rgb(p, q, h);
|
hue_to_rgb(p, q, h + 1.0 / 3.0),
|
||||||
b = 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,
|
(r * 255.0).round() as u8,
|
||||||
@@ -493,12 +489,6 @@ pub fn detect_windows_shell() -> Option<String> {
|
|||||||
None
|
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.
|
/// Detect the parent shell name on Windows, using the sysinfo crate.
|
||||||
pub fn detect_color_mode() -> ColorMode {
|
pub fn detect_color_mode() -> ColorMode {
|
||||||
if std::env::var("NO_COLOR").is_ok() {
|
if std::env::var("NO_COLOR").is_ok() {
|
||||||
|
|||||||
+218
-133
@@ -1,26 +1,19 @@
|
|||||||
use clap::{Parser, ValueEnum};
|
use clap::error::ErrorKind;
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
use crossterm::QueueableCommand;
|
use crossterm::QueueableCommand;
|
||||||
use crossterm::style::{Print, ResetColor, SetForegroundColor};
|
use crossterm::style::{Print, ResetColor};
|
||||||
use crossterm::terminal::size;
|
use crossterm::terminal::size;
|
||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
use std::io::{Write, stdout};
|
use std::io::{Write, stdout};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use unicode_width::UnicodeWidthStr;
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
mod color;
|
mod color;
|
||||||
mod mikuart;
|
mod mikuart;
|
||||||
use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv};
|
use color::{Color, ColorMode, detect_color_mode, lerp_color_hsv};
|
||||||
use mikuart::get_miku_art;
|
use mikuart::get_miku_art;
|
||||||
|
|
||||||
#[derive(Debug, Clone, ValueEnum)]
|
|
||||||
enum ColorFormat {
|
|
||||||
Hex,
|
|
||||||
Rgb,
|
|
||||||
Hsl,
|
|
||||||
Ansi,
|
|
||||||
Named,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version, about, long_about = None)]
|
#[command(version, about, long_about = None)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
@@ -28,7 +21,7 @@ pub struct Args {
|
|||||||
pub text: Option<String>,
|
pub text: Option<String>,
|
||||||
|
|
||||||
/// Style of the Miku art. A random one is chosen if not specified
|
/// 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>,
|
pub style: Option<i32>,
|
||||||
|
|
||||||
/// List all available art styles with their indices
|
/// List all available art styles with their indices
|
||||||
@@ -36,19 +29,19 @@ pub struct Args {
|
|||||||
pub list: bool,
|
pub list: bool,
|
||||||
|
|
||||||
/// Apply a smooth rainbow gradient across the ASCII art
|
/// Apply a smooth rainbow gradient across the ASCII art
|
||||||
#[arg(long)]
|
#[arg(long, conflicts_with_all = ["color", "gradient"])]
|
||||||
pub rainbow: bool,
|
pub rainbow: bool,
|
||||||
|
|
||||||
/// Saturation level for rainbow gradient (0-100)
|
/// 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,
|
pub saturation: u8,
|
||||||
|
|
||||||
/// Brightness level for rainbow gradient (0-100)
|
/// 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,
|
pub brightness: u8,
|
||||||
|
|
||||||
/// Override with a single solid color
|
/// Override with a single solid color
|
||||||
#[arg(long, value_name = "COLOR")]
|
#[arg(long, value_name = "COLOR", conflicts_with = "gradient")]
|
||||||
pub color: Option<String>,
|
pub color: Option<String>,
|
||||||
|
|
||||||
/// Define a custom two-color gradient (e.g., --gradient red:blue)
|
/// Define a custom two-color gradient (e.g., --gradient red:blue)
|
||||||
@@ -76,7 +69,21 @@ pub struct ColorConfig {
|
|||||||
pub debug: bool,
|
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
|
// Detect color mode
|
||||||
let mut color_mode = 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
|
// Parse single color if provided
|
||||||
let single_color = if let Some(color_str) = args.color.as_deref() {
|
let single_color = args
|
||||||
match Color::from_str(color_str) {
|
.color
|
||||||
Ok(color) => Some(color),
|
.as_deref()
|
||||||
Err(e) => {
|
.map(|color_str| {
|
||||||
eprintln!("Error parsing color '{}': {}", color_str, e);
|
Color::from_str(color_str).map_err(|error| {
|
||||||
eprintln!("Valid color formats:");
|
ColorConfigError(format!(
|
||||||
eprintln!(" - Named colors: red, blue, green, etc.");
|
"invalid color '{color_str}': {error}; {COLOR_FORMAT_HELP}"
|
||||||
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)");
|
.transpose()?;
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
// Parse gradient if provided
|
// Parse gradient if provided
|
||||||
let gradient = if let Some(gradient_str) = args.gradient.as_deref() {
|
let gradient = if let Some(gradient_str) = args.gradient.as_deref() {
|
||||||
let parts: Vec<&str> = gradient_str.split(':').collect();
|
let (start_str, end_str) = gradient_str.split_once(':').ok_or_else(|| {
|
||||||
if parts.len() != 2 {
|
ColorConfigError(
|
||||||
eprintln!("Error: Gradient format should be START:END (e.g., --gradient red:blue)");
|
"invalid gradient: expected START:END (for example, red:blue)".to_string(),
|
||||||
std::process::exit(1);
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
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]) {
|
let start = Color::from_str(start_str).map_err(|error| {
|
||||||
Ok(color) => color,
|
ColorConfigError(format!(
|
||||||
Err(e) => {
|
"invalid gradient start '{start_str}': {error}; {COLOR_FORMAT_HELP}"
|
||||||
eprintln!("Error parsing gradient start color '{}': {}", parts[0], e);
|
))
|
||||||
eprintln!("Valid color formats:");
|
})?;
|
||||||
eprintln!(" - Named colors: red, blue, green, etc.");
|
let end = Color::from_str(end_str).map_err(|error| {
|
||||||
eprintln!(" - Hex: #RRGGBB or #RGB (e.g., #FF0000 or #F00)");
|
ColorConfigError(format!(
|
||||||
eprintln!(" - RGB: R,G,B (e.g., 255,0,128)");
|
"invalid gradient end '{end_str}': {error}; {COLOR_FORMAT_HELP}"
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Some((start, end))
|
Some((start, end))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate saturation and brightness
|
// Keep this validation for direct callers as well as Clap-parsed CLI input.
|
||||||
let saturation = args.saturation.clamp(0, 100);
|
|
||||||
let brightness = args.brightness.clamp(0, 100);
|
|
||||||
|
|
||||||
// Check for conflicting options
|
|
||||||
if args.rainbow && single_color.is_some() {
|
if args.rainbow && single_color.is_some() {
|
||||||
eprintln!("Error: Cannot use --rainbow and --color together");
|
return Err(ColorConfigError(
|
||||||
std::process::exit(1);
|
"--rainbow cannot be used with --color".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if args.rainbow && gradient.is_some() {
|
if args.rainbow && gradient.is_some() {
|
||||||
eprintln!("Error: Cannot use --rainbow and --gradient together");
|
return Err(ColorConfigError(
|
||||||
std::process::exit(1);
|
"--rainbow cannot be used with --gradient".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if single_color.is_some() && gradient.is_some() {
|
if single_color.is_some() && gradient.is_some() {
|
||||||
eprintln!("Error: Cannot use --color and --gradient together");
|
return Err(ColorConfigError(
|
||||||
std::process::exit(1);
|
"--color cannot be used with --gradient".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine if ANSI is enabled (Windows toggle helper) — don't enable if --no-color
|
// 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 {
|
Ok(ColorConfig {
|
||||||
mode: color_mode,
|
mode: color_mode,
|
||||||
rainbow: args.rainbow,
|
rainbow: args.rainbow,
|
||||||
saturation,
|
saturation: args.saturation,
|
||||||
brightness,
|
brightness: args.brightness,
|
||||||
single_color,
|
single_color,
|
||||||
gradient,
|
gradient,
|
||||||
color_cache: Vec::new(),
|
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> {
|
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 mut lines = Vec::new();
|
||||||
let words: Vec<&str> = text.split_whitespace().collect();
|
|
||||||
let mut current_line = String::new();
|
let mut current_line = String::new();
|
||||||
|
|
||||||
for word in words {
|
for word in text.split_whitespace() {
|
||||||
let word_width = word.width();
|
let word_width = word.width();
|
||||||
let current_width = current_line.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() {
|
if !current_line.is_empty() {
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
}
|
}
|
||||||
current_line.push_str(word);
|
current_line.push_str(word);
|
||||||
} else {
|
continue;
|
||||||
if !current_line.is_empty() {
|
}
|
||||||
lines.push(current_line);
|
|
||||||
|
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
|
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) {
|
pub fn generate_rainbow_cache(color_config: &mut ColorConfig, total_chars: usize) {
|
||||||
color_config.color_cache.clear();
|
color_config.color_cache.clear();
|
||||||
|
|
||||||
@@ -309,7 +343,11 @@ pub fn generate_gradient_cache(
|
|||||||
|
|
||||||
// Generate smooth gradient across all characters
|
// Generate smooth gradient across all characters
|
||||||
for i in 0..total_chars {
|
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);
|
let color = lerp_color_hsv(start, end, t);
|
||||||
color_config.color_cache.push(color);
|
color_config.color_cache.push(color);
|
||||||
}
|
}
|
||||||
@@ -359,8 +397,15 @@ fn draw_miku_says(
|
|||||||
let speech_bubble_lines = get_speech_bubble_lines(text);
|
let speech_bubble_lines = get_speech_bubble_lines(text);
|
||||||
let miku_art = get_miku_art(Some(style), None);
|
let miku_art = get_miku_art(Some(style), None);
|
||||||
|
|
||||||
// Calculate total character count for color distribution
|
// Distribute colors across characters that are actually colored.
|
||||||
let total_chars: usize = miku_art.iter().map(|line| line.chars().count()).sum();
|
let total_chars: usize = miku_art
|
||||||
|
.iter()
|
||||||
|
.map(|line| {
|
||||||
|
line.chars()
|
||||||
|
.filter(|character| !character.is_whitespace())
|
||||||
|
.count()
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
|
||||||
// Generate color cache if needed
|
// Generate color cache if needed
|
||||||
if color_config.rainbow {
|
if color_config.rainbow {
|
||||||
@@ -443,36 +488,36 @@ fn draw_miku_says(
|
|||||||
if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() {
|
if color_config.mode != ColorMode::NoColor && !color_config.color_cache.is_empty() {
|
||||||
// Use crossterm queueing to color characters so it's platform-agnostic
|
// Use crossterm queueing to color characters so it's platform-agnostic
|
||||||
let mut stdout = stdout();
|
let mut stdout = stdout();
|
||||||
stdout.queue(Print(horizontal_padding.clone())).ok();
|
stdout.queue(Print(horizontal_padding.clone()))?;
|
||||||
|
|
||||||
for c in line.chars() {
|
for c in line.chars() {
|
||||||
if c.is_whitespace() {
|
if c.is_whitespace() {
|
||||||
stdout.queue(Print(c)).ok();
|
stdout.queue(Print(c))?;
|
||||||
} else if color_index < color_config.color_cache.len() {
|
} else if color_index < color_config.color_cache.len() {
|
||||||
let color = &color_config.color_cache[color_index];
|
let color = &color_config.color_cache[color_index];
|
||||||
let ct_color = color::to_crossterm_color(color);
|
stdout.queue(Print(color.to_ansi_fg(color_config.mode)))?;
|
||||||
stdout.queue(SetForegroundColor(ct_color)).ok();
|
stdout.queue(Print(c))?;
|
||||||
stdout.queue(Print(c)).ok();
|
stdout.queue(ResetColor)?;
|
||||||
stdout.queue(ResetColor).ok();
|
|
||||||
color_index += 1;
|
color_index += 1;
|
||||||
} else {
|
} else {
|
||||||
stdout.queue(Print(c)).ok();
|
stdout.queue(Print(c))?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stdout.queue(Print("\n")).ok();
|
stdout.queue(Print("\n"))?;
|
||||||
stdout.flush().ok();
|
stdout.flush()?;
|
||||||
continue;
|
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
|
// Apply single solid color using crossterm
|
||||||
let mut stdout = stdout();
|
let mut stdout = stdout();
|
||||||
let ct_color = color::to_crossterm_color(single_color);
|
stdout.queue(Print(horizontal_padding.clone()))?;
|
||||||
stdout.queue(Print(horizontal_padding.clone())).ok();
|
stdout.queue(Print(single_color.to_ansi_fg(color_config.mode)))?;
|
||||||
stdout.queue(SetForegroundColor(ct_color)).ok();
|
stdout.queue(Print(line))?;
|
||||||
stdout.queue(Print(line)).ok();
|
stdout.queue(ResetColor)?;
|
||||||
stdout.queue(ResetColor).ok();
|
stdout.queue(Print("\n"))?;
|
||||||
stdout.queue(Print("\n")).ok();
|
stdout.flush()?;
|
||||||
stdout.flush().ok();
|
|
||||||
} else {
|
} else {
|
||||||
// No coloring
|
// No coloring
|
||||||
println!("{}{}", horizontal_padding, line);
|
println!("{}{}", horizontal_padding, line);
|
||||||
@@ -500,10 +545,27 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::process::exit(1);
|
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
|
// 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 {
|
if color_config.debug {
|
||||||
println!("Detected color mode: {:?}", color_config.mode);
|
println!("Detected color mode: {:?}", color_config.mode);
|
||||||
@@ -518,25 +580,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return Ok(());
|
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)?;
|
draw_miku_says(&text, style, &mut color_config)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -566,7 +609,8 @@ mod tests {
|
|||||||
fn test_wrap_text_long_word() {
|
fn test_wrap_text_long_word() {
|
||||||
let text = "ThisIsAVeryLongWordThatExceedsMaxWidth";
|
let text = "ThisIsAVeryLongWordThatExceedsMaxWidth";
|
||||||
let wrapped = wrap_text(text, 10);
|
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]
|
#[test]
|
||||||
@@ -667,7 +711,8 @@ mod tests {
|
|||||||
// Test with single very long word
|
// Test with single very long word
|
||||||
let text = "Supercalifragilisticexpialidocious";
|
let text = "Supercalifragilisticexpialidocious";
|
||||||
let wrapped = wrap_text(text, 10);
|
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
|
// Test with exact width match
|
||||||
let text = "Hello world";
|
let text = "Hello world";
|
||||||
@@ -690,7 +735,20 @@ mod tests {
|
|||||||
let text = "Supercalifragilisticexpialidocious";
|
let text = "Supercalifragilisticexpialidocious";
|
||||||
let bubble = get_speech_bubble_lines(text);
|
let bubble = get_speech_bubble_lines(text);
|
||||||
assert!(bubble.len() >= 5);
|
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]
|
#[test]
|
||||||
@@ -793,11 +851,38 @@ mod tests {
|
|||||||
generate_gradient_cache(&mut config, &start, &end, 10);
|
generate_gradient_cache(&mut config, &start, &end, 10);
|
||||||
assert_eq!(config.color_cache.len(), 10);
|
assert_eq!(config.color_cache.len(), 10);
|
||||||
|
|
||||||
// Check that first color is close to start
|
|
||||||
let first_color = &config.color_cache[0];
|
let first_color = &config.color_cache[0];
|
||||||
// Check that last color is close to end
|
|
||||||
let last_color = &config.color_cache[9];
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
use std::str;
|
use std::str;
|
||||||
|
|
||||||
|
fn command() -> Command {
|
||||||
|
Command::new(env!("CARGO_BIN_EXE_mikusays"))
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_list_styles() {
|
fn test_cli_list_styles() {
|
||||||
let output = Command::new("target/debug/mikusays")
|
let output = command()
|
||||||
.arg("--list")
|
.arg("--list")
|
||||||
.output()
|
.output()
|
||||||
.expect("Failed to execute command");
|
.expect("Failed to execute command");
|
||||||
@@ -19,9 +23,7 @@ fn test_cli_list_styles() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_error_no_text() {
|
fn test_cli_error_no_text() {
|
||||||
let output = Command::new("target/debug/mikusays")
|
let output = command().output().expect("Failed to execute command");
|
||||||
.output()
|
|
||||||
.expect("Failed to execute command");
|
|
||||||
|
|
||||||
assert!(!output.status.success());
|
assert!(!output.status.success());
|
||||||
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
|
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
|
||||||
@@ -30,7 +32,7 @@ fn test_cli_error_no_text() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cli_error_conflicting_args() {
|
fn test_cli_error_conflicting_args() {
|
||||||
let output = Command::new("target/debug/mikusays")
|
let output = command()
|
||||||
.arg("Test")
|
.arg("Test")
|
||||||
.arg("--rainbow")
|
.arg("--rainbow")
|
||||||
.arg("--color")
|
.arg("--color")
|
||||||
@@ -40,16 +42,88 @@ fn test_cli_error_conflicting_args() {
|
|||||||
|
|
||||||
assert!(!output.status.success());
|
assert!(!output.status.success());
|
||||||
let stderr = str::from_utf8(&output.stderr).expect("Invalid UTF-8");
|
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]
|
#[test]
|
||||||
fn test_cli_basic_functionality() {
|
fn test_cli_basic_functionality() {
|
||||||
// Test that the binary runs and produces some output
|
// Test that the binary runs and produces some output
|
||||||
let output = Command::new("target/debug/mikusays")
|
let output = command()
|
||||||
.arg("--list")
|
.arg("--list")
|
||||||
.output()
|
.output()
|
||||||
.expect("Failed to execute command");
|
.expect("Failed to execute command");
|
||||||
|
|
||||||
assert!(output.status.success());
|
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'"));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user