├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── changelog-ci-config.yaml └── workflows │ ├── changelog-ci.yml │ ├── cut-tag.yml │ ├── pr-title-check.yml │ ├── release.yml │ └── rust-ci.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── scripts └── completions │ └── thoth.nu ├── src ├── cli.rs ├── clipboard.rs ├── code_block_popup.rs ├── config.rs ├── formatter.rs ├── lib.rs ├── main.rs ├── markdown_renderer.rs ├── scrollable_textarea.rs ├── theme.rs ├── title_popup.rs ├── title_select_popup.rs ├── ui.rs ├── ui_handler.rs └── utils.rs └── tests └── integration_tests.rs /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # This CODEOWNERS file requires approval from either jooaf or jafriyie1 for all changes 2 | 3 | # Global rule: require approval from at least one of the users for all files 4 | * @jooaf 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help improve Thoth 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Please complete the following information):** 27 | - OS: [e.g. macOS Sonoma] 28 | - Terminal [e.g. Kitty] 29 | - Version [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/changelog-ci-config.yaml: -------------------------------------------------------------------------------- 1 | changelog_type: 'pull_request' 2 | header_prefix: '# Changelog' 3 | commit_changelog: false 4 | comment_changelog: true 5 | pull_request_title_regex: '^(Fix|Feature|Documentation|Improvement):.+$' 6 | group_config: 7 | - title: 'Fix' 8 | labels: 9 | - 'Fix' 10 | - title: 'Feature' 11 | labels: 12 | - 'Feature' 13 | - title: 'Documentation' 14 | labels: 15 | - 'Documentation' 16 | - title: 'Improvement' 17 | labels: 18 | - 'Improvement' 19 | version_regex: 'v?[0-9]+.[0-9]+.[0-9]+' 20 | include_unlabeled: true 21 | -------------------------------------------------------------------------------- /.github/workflows/changelog-ci.yml: -------------------------------------------------------------------------------- 1 | name: Changelog CI 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, reopened, synchronize] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Run Changelog CI 13 | uses: saadmk11/changelog-ci@v1.1.2 14 | with: 15 | config_file: .github/changelog-ci-config.yaml 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.github/workflows/cut-tag.yml: -------------------------------------------------------------------------------- 1 | name: Auto Tag, Update Version, and Changelog 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | auto_tag_and_update: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | steps: 12 | - uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - name: Debug Info 17 | run: | 18 | echo "GitHub Ref: ${{ github.ref }}" 19 | echo "GitHub Event Name: ${{ github.event_name }}" 20 | echo "Last Commit Message:" 21 | git log -1 --pretty=%B 22 | echo "Changed files:" 23 | git diff --name-only HEAD^ 24 | 25 | - name: Get latest tag 26 | id: get_latest_tag 27 | run: | 28 | git fetch --tags 29 | LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.1.0") 30 | echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_OUTPUT 31 | echo "Latest tag: $LATEST_TAG" 32 | 33 | - name: Bump version 34 | id: bump_version 35 | run: | 36 | LATEST_TAG=${{ steps.get_latest_tag.outputs.LATEST_TAG }} 37 | LATEST_VERSION=${LATEST_TAG#v} 38 | IFS='.' read -ra VERSION_PARTS <<< "$LATEST_VERSION" 39 | MAJOR=${VERSION_PARTS[0]} 40 | MINOR=${VERSION_PARTS[1]} 41 | PATCH=${VERSION_PARTS[2]} 42 | NEW_PATCH=$((PATCH + 1)) 43 | NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH" 44 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT 45 | echo "New version: $NEW_VERSION" 46 | 47 | - name: Update Cargo.toml 48 | run: | 49 | sed -i 's/^version = ".*"/version = "${{ steps.bump_version.outputs.NEW_VERSION }}"/' Cargo.toml 50 | echo "Updated Cargo.toml:" 51 | grep version Cargo.toml 52 | 53 | - name: Update Changelog 54 | run: | 55 | NEW_VERSION=${{ steps.bump_version.outputs.NEW_VERSION }} 56 | TODAY=$(date +%Y-%m-%d) 57 | 58 | # Check if CHANGELOG.md exists, create it if it doesn't 59 | if [ ! -f CHANGELOG.md ]; then 60 | echo "# Changelog" > CHANGELOG.md 61 | echo "" >> CHANGELOG.md 62 | fi 63 | 64 | # Get all commit messages since the last tag 65 | COMMITS=$(git log $(git describe --tags --abbrev=0)..HEAD --pretty=format:"- %s") 66 | 67 | # Prepend new version section to CHANGELOG.md 68 | sed -i "1i\\\n## [$NEW_VERSION] - $TODAY\n\n$COMMITS\n" CHANGELOG.md 69 | 70 | echo "Updated CHANGELOG.md with new version $NEW_VERSION" 71 | cat CHANGELOG.md 72 | 73 | - name: Commit and push changes 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | run: | 77 | git config --local user.email "action@github.com" 78 | git config --local user.name "GitHub Action" 79 | git add Cargo.toml CHANGELOG.md 80 | git commit -m "Bump version to ${{ steps.bump_version.outputs.NEW_VERSION }} and update CHANGELOG.md" 81 | git push 82 | echo "Pushed changes to Cargo.toml and CHANGELOG.md" 83 | echo "New git status:" 84 | git status 85 | 86 | - name: Create new tag 87 | env: 88 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 89 | run: | 90 | git tag -a v${{ steps.bump_version.outputs.NEW_VERSION }} -m "Release v${{ steps.bump_version.outputs.NEW_VERSION }}" 91 | git push origin v${{ steps.bump_version.outputs.NEW_VERSION }} 92 | echo "Created and pushed new tag: v${{ steps.bump_version.outputs.NEW_VERSION }}" 93 | echo "All tags:" 94 | git tag -l 95 | -------------------------------------------------------------------------------- /.github/workflows/pr-title-check.yml: -------------------------------------------------------------------------------- 1 | name: PR Title Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited, synchronize, reopened] 6 | 7 | jobs: 8 | check-pr-title: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Install Nushell 13 | uses: hustcer/setup-nu@v3 14 | with: 15 | version: 0.94.1 16 | - name: Check PR Title 17 | env: 18 | PR_TITLE: ${{ github.event.pull_request.title }} 19 | run: | 20 | nu -c ' 21 | let title = $env.PR_TITLE 22 | let titles = [Fix, Feature, Improvement, Documentation] 23 | let type = $title | parse "{Type}:{Title}" | get Type.0 | str trim 24 | if ($type in $titles) { 25 | print "PR title is correctly formatted." 26 | exit 0 27 | } else { 28 | print "PR title does not match the required format. It should start with one of: Fix:, Feature:, Documentation:, or Improvement:" 29 | exit 1 30 | } 31 | ' 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release (e.g., 1.2.3)' 8 | required: true 9 | previous_version: 10 | description: 'Previous version (e.g., 1.2.2)' 11 | required: true 12 | 13 | jobs: 14 | create_release: 15 | name: Create Release 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | outputs: 20 | release_id: ${{ steps.create_release.outputs.id }} 21 | upload_url: ${{ steps.create_release.outputs.upload_url }} 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Extract Changelog 26 | id: extract_changelog 27 | run: | 28 | CURRENT_VERSION=${{ github.event.inputs.version }} 29 | PREVIOUS_VERSION=${{ github.event.inputs.previous_version }} 30 | CHANGELOG_CONTENT=$(awk "/## \[${CURRENT_VERSION}\]/,/## \[${PREVIOUS_VERSION}\]/" CHANGELOG.md | sed '$d') 31 | echo "CHANGELOG_CONTENT<> $GITHUB_OUTPUT 32 | echo "$CHANGELOG_CONTENT" >> $GITHUB_OUTPUT 33 | echo "EOF" >> $GITHUB_OUTPUT 34 | 35 | - name: Create Release 36 | id: create_release 37 | uses: softprops/action-gh-release@v1 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | tag_name: v${{ github.event.inputs.version }} 42 | name: v${{ github.event.inputs.version }} 43 | body: | 44 | ${{ steps.extract_changelog.outputs.CHANGELOG_CONTENT }} 45 | draft: false 46 | prerelease: false 47 | generate_release_notes: true 48 | 49 | build_and_release: 50 | name: Build and Release 51 | needs: create_release 52 | runs-on: ${{ matrix.os }} 53 | permissions: 54 | contents: write 55 | strategy: 56 | matrix: 57 | include: 58 | - os: ubuntu-latest 59 | target: x86_64-unknown-linux-gnu 60 | artifact_name: thoth 61 | asset_name: thoth_${{ github.event.inputs.version }}_linux_amd64 62 | - os: ubuntu-latest 63 | target: aarch64-unknown-linux-gnu 64 | artifact_name: thoth 65 | asset_name: thoth_${{ github.event.inputs.version }}_linux_arm64 66 | - os: ubuntu-latest 67 | target: x86_64-unknown-linux-musl 68 | artifact_name: thoth 69 | asset_name: thoth_${{ github.event.inputs.version }}_linux-musl_amd64 70 | - os: ubuntu-latest 71 | target: aarch64-unknown-linux-musl 72 | artifact_name: thoth 73 | asset_name: thoth_${{ github.event.inputs.version }}_linux-musl_arm64 74 | - os: macos-latest 75 | target: x86_64-apple-darwin 76 | artifact_name: thoth 77 | asset_name: thoth_${{ github.event.inputs.version }}_darwin_amd64 78 | - os: macos-latest 79 | target: aarch64-apple-darwin 80 | artifact_name: thoth 81 | asset_name: thoth_${{ github.event.inputs.version }}_darwin_arm64 82 | 83 | steps: 84 | - uses: actions/checkout@v3 85 | 86 | - name: Install Rust 87 | uses: actions-rs/toolchain@v1 88 | with: 89 | profile: minimal 90 | toolchain: stable 91 | override: true 92 | target: ${{ matrix.target }} 93 | 94 | - name: Install cross 95 | run: cargo install cross 96 | 97 | - name: Build 98 | run: cross build --release --target ${{ matrix.target }} 99 | 100 | - name: Package 101 | run: | 102 | mkdir -p dist 103 | cp target/${{ matrix.target }}/release/${{ matrix.artifact_name }} dist/ 104 | cd dist 105 | tar -czvf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} 106 | 107 | - name: Calculate SHA256 108 | run: | 109 | cd dist 110 | echo "sha256=$(shasum -a 256 ${{ matrix.asset_name }}.tar.gz | awk '{print $1}')" >> $GITHUB_OUTPUT 111 | id: calc_sha256 112 | 113 | - name: Create DEB package (Linux only) 114 | if: runner.os == 'Linux' && !contains(matrix.target, 'musl') 115 | run: | 116 | sudo apt-get install -y fakeroot 117 | mkdir -p dist/deb/DEBIAN dist/deb/usr/bin 118 | cp target/${{ matrix.target }}/release/${{ matrix.artifact_name }} dist/deb/usr/bin/ 119 | cat << EOF > dist/deb/DEBIAN/control 120 | Package: thoth 121 | Version: ${{ github.event.inputs.version }} 122 | Architecture: ${{ contains(matrix.target, 'aarch64') && 'arm64' || 'amd64' }} 123 | Maintainer: Thoth Maintainer 124 | Description: Thoth application 125 | EOF 126 | fakeroot dpkg-deb --build dist/deb dist/${{ matrix.asset_name }}.deb 127 | 128 | - name: Create RPM package (Linux only) 129 | if: runner.os == 'Linux' && !contains(matrix.target, 'musl') 130 | run: | 131 | sudo apt-get install -y rpm 132 | mkdir -p dist/rpm/{BUILD,RPMS,SOURCES,SPECS,SRPMS} 133 | cp target/${{ matrix.target }}/release/${{ matrix.artifact_name }} dist/rpm/SOURCES/ 134 | cat << EOF > dist/rpm/SPECS/thoth.spec 135 | Name: thoth 136 | Version: ${{ github.event.inputs.version }} 137 | Release: 1 138 | Summary: Thoth application 139 | License: MIT 140 | BuildArch: $(uname -m) 141 | 142 | %description 143 | A terminal scratchpad akin to Heynote 144 | 145 | %install 146 | mkdir -p %{buildroot}/usr/bin 147 | cp %{_sourcedir}/thoth %{buildroot}/usr/bin/thoth 148 | 149 | %files 150 | /usr/bin/thoth 151 | 152 | %define __strip /bin/true 153 | %define __spec_install_post %{nil} 154 | EOF 155 | rpmbuild -bb --define "_topdir $(pwd)/dist/rpm" dist/rpm/SPECS/thoth.spec 156 | find dist/rpm/RPMS -name '*.rpm' -exec mv {} dist/${{ matrix.asset_name }}.rpm \; 157 | 158 | - name: Upload Release Asset (tar.gz) 159 | uses: actions/upload-release-asset@v1 160 | env: 161 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 162 | with: 163 | upload_url: ${{ needs.create_release.outputs.upload_url }} 164 | asset_path: ./dist/${{ matrix.asset_name }}.tar.gz 165 | asset_name: ${{ matrix.asset_name }}.tar.gz 166 | asset_content_type: application/gzip 167 | 168 | - name: Upload Release Asset (deb) 169 | if: runner.os == 'Linux' && !contains(matrix.target, 'musl') 170 | uses: actions/upload-release-asset@v1 171 | env: 172 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 173 | with: 174 | upload_url: ${{ needs.create_release.outputs.upload_url }} 175 | asset_path: ./dist/${{ matrix.asset_name }}.deb 176 | asset_name: ${{ matrix.asset_name }}.deb 177 | asset_content_type: application/vnd.debian.binary-package 178 | 179 | - name: Upload Release Asset (rpm) 180 | if: runner.os == 'Linux' && !contains(matrix.target, 'musl') 181 | uses: actions/upload-release-asset@v1 182 | env: 183 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 184 | with: 185 | upload_url: ${{ needs.create_release.outputs.upload_url }} 186 | asset_path: ./dist/${{ matrix.asset_name }}.rpm 187 | asset_name: ${{ matrix.asset_name }}.rpm 188 | asset_content_type: application/x-rpm 189 | 190 | update_homebrew_formula: 191 | name: Update Homebrew Formula 192 | needs: [create_release, build_and_release] 193 | runs-on: ubuntu-latest 194 | steps: 195 | - name: Download release assets 196 | run: | 197 | curl -LO "https://github.com/jooaf/thoth/releases/download/v${{ github.event.inputs.version }}/thoth_${{ github.event.inputs.version }}_darwin_amd64.tar.gz" 198 | curl -LO "https://github.com/jooaf/thoth/releases/download/v${{ github.event.inputs.version }}/thoth_${{ github.event.inputs.version }}_darwin_arm64.tar.gz" 199 | 200 | - name: Calculate SHA256 201 | id: calc_sha256 202 | run: | 203 | AMD64_SHA256=$(sha256sum thoth_${{ github.event.inputs.version }}_darwin_amd64.tar.gz | awk '{print $1}') 204 | ARM64_SHA256=$(sha256sum thoth_${{ github.event.inputs.version }}_darwin_arm64.tar.gz | awk '{print $1}') 205 | echo "darwin_amd64_sha256=$AMD64_SHA256" >> $GITHUB_OUTPUT 206 | echo "darwin_arm64_sha256=$ARM64_SHA256" >> $GITHUB_OUTPUT 207 | echo "Debug: AMD64 SHA256: $AMD64_SHA256" 208 | echo "Debug: ARM64 SHA256: $ARM64_SHA256" 209 | 210 | - name: Checkout homebrew-thoth 211 | uses: actions/checkout@v3 212 | with: 213 | repository: jooaf/homebrew-thoth 214 | token: ${{ secrets.HOMEBREW_TAP_TOKEN }} 215 | path: homebrew-thoth 216 | 217 | - name: Update formula 218 | env: 219 | DARWIN_AMD64_SHA256: ${{ steps.calc_sha256.outputs.darwin_amd64_sha256 }} 220 | DARWIN_ARM64_SHA256: ${{ steps.calc_sha256.outputs.darwin_arm64_sha256 }} 221 | run: | 222 | cd homebrew-thoth 223 | echo "Debug: Using AMD64 SHA256: $DARWIN_AMD64_SHA256" 224 | echo "Debug: Using ARM64 SHA256: $DARWIN_ARM64_SHA256" 225 | cat << EOF > thoth.rb 226 | class Thoth < Formula 227 | desc "A terminal scratchpad akin to Heynote" 228 | homepage "https://github.com/jooaf/thoth" 229 | version "${{ github.event.inputs.version }}" 230 | 231 | on_macos do 232 | if Hardware::CPU.intel? 233 | url "https://github.com/jooaf/thoth/releases/download/v#{version}/thoth_#{version}_darwin_amd64.tar.gz" 234 | sha256 "$DARWIN_AMD64_SHA256" 235 | else 236 | url "https://github.com/jooaf/thoth/releases/download/v#{version}/thoth_#{version}_darwin_arm64.tar.gz" 237 | sha256 "$DARWIN_ARM64_SHA256" 238 | end 239 | end 240 | 241 | def install 242 | bin.install "thoth" 243 | end 244 | 245 | test do 246 | assert_match "thoth version #{version}", shell_output("#{bin}/thoth --version") 247 | end 248 | end 249 | EOF 250 | 251 | - name: Commit and push changes 252 | run: | 253 | cd homebrew-thoth 254 | git config user.name github-actions 255 | git config user.email github-actions@github.com 256 | git add thoth.rb 257 | git commit -m "Update Thoth to ${{ github.event.inputs.version }}" 258 | git push 259 | 260 | publish_to_cargo: 261 | name: Publish to Cargo 262 | needs: create_release 263 | runs-on: ubuntu-latest 264 | steps: 265 | - uses: actions/checkout@v3 266 | 267 | - name: Install Rust 268 | uses: actions-rs/toolchain@v1 269 | with: 270 | profile: minimal 271 | toolchain: stable 272 | override: true 273 | 274 | - name: Update Cargo.lock 275 | run: | 276 | cargo update 277 | git config user.name github-actions 278 | git config user.email github-actions@github.com 279 | git add Cargo.lock 280 | git commit -m "Update Cargo.lock" || echo "No changes to commit" 281 | 282 | - name: Verify Cargo Token 283 | run: | 284 | if [ -z "${{ secrets.CARGO_REGISTRY_TOKEN }}" ]; then 285 | echo "CARGO_REGISTRY_TOKEN is not set" 286 | exit 1 287 | else 288 | echo "CARGO_REGISTRY_TOKEN is set" 289 | fi 290 | 291 | - name: Publish to Cargo 292 | run: | 293 | cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} --verbose || { 294 | echo "Cargo publish failed. Checking package status..." 295 | cargo package --list 296 | exit 1 297 | } 298 | env: 299 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 300 | -------------------------------------------------------------------------------- /.github/workflows/rust-ci.yml: -------------------------------------------------------------------------------- 1 | name: Rust CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Install Nushell 20 | uses: hustcer/setup-nu@v3 21 | with: 22 | version: 0.94.1 23 | 24 | - name: Install Rust toolchain 25 | uses: actions-rs/toolchain@v1 26 | with: 27 | profile: minimal 28 | toolchain: stable 29 | override: true 30 | components: rustfmt, clippy 31 | 32 | - name: Cache dependencies 33 | uses: actions/cache@v3 34 | env: 35 | cache-name: cache-rust-dependencies 36 | with: 37 | path: | 38 | ~/.cargo 39 | target/ 40 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }} 41 | restore-keys: | 42 | ${{ runner.os }}-build-${{ env.cache-name }}- 43 | ${{ runner.os }}-build- 44 | ${{ runner.os }}- 45 | 46 | - name: Run cargo fmt 47 | run: nu -c 'cargo fmt --all -- --check' 48 | 49 | - name: Run clippy 50 | run: nu -c 'cargo clippy -- -D warnings' 51 | 52 | - name: Run tests 53 | run: nu -c 'cargo test --verbose' 54 | 55 | - name: Run integration tests 56 | run: nu -c 'cargo test --test integration_tests' 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | thoth_notes.md 3 | **/.DS_Store 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [0.1.84] - 2025-05-30 3 | 4 | - Fix: Fixing text color in light and dark mode (#34) 5 | 6 | 7 | ## [0.1.83] - 2025-04-14 8 | 9 | - Documentation: Update README to reflect copy block feature (#33) 10 | 11 | 12 | ## [0.1.82] - 2025-04-14 13 | 14 | - Feature: Adding copy code blocks in TUI (#32) 15 | 16 | 17 | ## [0.1.81] - 2025-04-11 18 | 19 | - Feature: Adding Light and Dark Mode (#31) 20 | 21 | 22 | ## [0.1.80] - 2025-04-10 23 | 24 | - Fix: Handling 'j' and 'k' in edit mode (#30) 25 | 26 | 27 | ## [0.1.79] - 2025-04-09 28 | 29 | - Improvement: Updating markdown renderer (#29) 30 | 31 | 32 | ## [0.1.78] - 2025-03-10 33 | 34 | - Fix: Copy Block in TUI for Wayland; Adding clipboard mocks (#28) 35 | 36 | 37 | ## [0.1.77] - 2025-02-24 38 | 39 | - Fix: Prevent duplicate input on Windows (#27) 40 | 41 | 42 | ## [0.1.76] - 2025-01-24 43 | 44 | - Fix: Scroll offset in Title Select Popup was off (#25) 45 | 46 | 47 | ## [0.1.75] - 2025-01-21 48 | 49 | - Documentation: Adding "Done!" to fuzzy finder bullet point (#24) 50 | 51 | 52 | ## [0.1.74] - 2025-01-21 53 | 54 | - Feature: Adding ability to fuzzy match in title select popup (#23) 55 | 56 | 57 | ## [0.1.73] - 2025-01-20 58 | 59 | - Improvement: Continuous scroll across components (#22) 60 | 61 | 62 | ## [0.1.72] - 2025-01-07 63 | 64 | - Improvement: Adding fix for scrolling in full screen mode (#21) 65 | 66 | 67 | ## [0.1.71] - 2025-01-02 68 | 69 | - Improvement: Adding LTO in Cargo.toml for release (#20) 70 | 71 | 72 | ## [0.1.70] - 2024-12-30 73 | 74 | - Documentation: Fixing a typo and adding instructions in README (#18) 75 | 76 | 77 | ## [0.1.69] - 2024-12-29 78 | 79 | - Documentation: Adding load_backup command to README (#17) 80 | 81 | 82 | ## [0.1.68] - 2024-12-17 83 | 84 | - Improvement: Adding popup after copying a block (#16) 85 | 86 | 87 | ## [0.1.67] - 2024-12-17 88 | 89 | - Feature: Add Load Backup command if the original file gets corrupted (#15) 90 | 91 | 92 | ## [0.1.66] - 2024-10-04 93 | 94 | - Documentation: Indicating that the save backup feature is completed (#14) 95 | 96 | 97 | ## [0.1.65] - 2024-10-04 98 | 99 | - Feature: Adding thread to save backups (#13) 100 | 101 | 102 | ## [0.1.64] - 2024-10-04 103 | 104 | - Fix: Escape commands popup back to edit mode (#12) 105 | 106 | 107 | ## [0.1.63] - 2024-09-20 108 | 109 | - Fix: Daemon clipboard on linux (#11) 110 | 111 | 112 | ## [0.1.62] - 2024-09-13 113 | 114 | - Documentation: Add AUR instructions to README.md (#7) 115 | 116 | 117 | ## [0.1.61] - 2024-08-11 118 | 119 | - Documentation: adding future TODOs to README (#6) 120 | 121 | 122 | ## [0.1.60] - 2024-08-11 123 | 124 | - Improvement: updating release.yml to include contents of CHANGELOG (#5) 125 | 126 | 127 | ## [0.1.59] - 2024-08-11 128 | 129 | - Fix: Add update CHANEGLOG workflow to cut-tag workflow (#4) 130 | 131 | 132 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | Contributions in any way is greatly appreciated :) ! Feel free to report problems, bug fixes, implementing features, etc. 4 | 5 | ## Opening an issue 6 | 7 | ### Bug reports 8 | 9 | When filing a bug report, fill out the bug report [template](https://github.com/jooaf/thoth/issues/new?assignees=&labels=&projects=&template=bug_report.md&title=). Please add all the necessary details as it'll make it to reproduce the problem. 10 | 11 | ### Feature requests 12 | 13 | Please fill out the feature request [template](https://github.com/jooaf/thoth/issues/new?assignees=&labels=&projects=&template=feature_request.md&title=). Please provide details about your feature idea, as well as why this suggestion will be useful. 14 | Note: Thoth is basically feature complete as the point is to keep the application simple like Heynote. However, I don't want this to discourage people from bringing up feature ideas! There may be some features that actually make sense to incorporate into Thoth. 15 | 16 | ## Pull requests 17 | 18 | If you want to directly contribute to the code, look no further! Here is an expected workflow for a pull request: 19 | 20 | 1. Fork the project. 21 | 2. Make your changes. 22 | - Make sure to run tests and `clippy` before pushing to your branch 23 | - `cargo test` 24 | - `cargo clippy -- -D warnings` 25 | - Note: if you don't have clippy installed you can add it to your toolchain via `rustup component add clippy` 26 | 4. If you are adding a new feature, please update the README.md. 27 | 5. Commit and create a pull request to merge into the main branch. Please fill out the pull request template. 28 | 6. Make sure that your PR is prepended with either of these in its title, Fix: , Documentation: , Improvement: , Feature: . 29 | 6. Ask a maintainer to review your pull request. 30 | 7. Check if the CI workflow passes. These consist of clippy lints, rustfmt checks, and basic tests. If you are a first-time contributor, you may need to wait for a maintainer to let CI run. 31 | 8. If changes are suggested or any comments are made, they should probably be addressed. 32 | 9. Once it looks good, it'll be merged! PRs will be squashed to maintain repo cleanliness. 33 | 34 | 35 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "thoth-cli" 3 | version = "0.1.84" 4 | edition = "2021" 5 | authors = [ 6 | "Joel Afriyie ", 7 | "Joel Afriyie ", 8 | ] 9 | description = "A terminal scratchpad akin to Heynote" 10 | license = "MIT" 11 | repository = "https://github.com/jooaf/thoth" 12 | readme = "README.md" 13 | keywords = ["terminal", "scratchpad", "note-taking"] 14 | categories = ["command-line-utilities"] 15 | 16 | [profile.release] 17 | codegen-units = 1 18 | lto = true 19 | 20 | [dependencies] 21 | serde_json = "1.0" 22 | serde = { version = "1.0", features = ["derive"] } 23 | toml = "0.8" 24 | syntect = "5.1" 25 | pulldown-cmark = { version = "0.11.0" } 26 | pulldown-cmark-to-cmark = "15.0.1" 27 | ratatui = "0.27.0" 28 | crossterm = { version = "0.27.0", features = ["bracketed-paste"] } 29 | tui-textarea = "0.5.1" 30 | anyhow = "1.0.86" 31 | clap = { version = "4.3", features = ["derive"] } 32 | dirs = "5.0" 33 | atty = "0.2.14" 34 | tempfile = "3.2" 35 | syntect-tui = "3.0.2" 36 | unicode-width = "0.1.10" 37 | rand = "0.8.5" 38 | once_cell = "1.19.0" 39 | arboard = "3.4.1" 40 | fuzzy-matcher = "0.3.7" 41 | lazy_static = "1.5.0" 42 | 43 | [[bin]] 44 | name = "thoth" 45 | path = "src/main.rs" 46 | 47 | [dev-dependencies] 48 | mockall = "0.11" 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Joel Afriyie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Thoth 2 | 3 | [![CI](https://github.com/jooaf/thoth/actions/workflows/rust-ci.yml/badge.svg)](https://github.com/jooaf/thoth/actions/workflows/rust-ci.yml) 4 | [![Release](https://img.shields.io/github/v/release/jooaf/thoth)](https://github.com/jooaf/thoth/releases) 5 | [![Auto Tag and Update Version](https://github.com/jooaf/thoth/actions/workflows/cut-tag.yml/badge.svg)](https://github.com/jooaf/thoth/actions/workflows/cut-tag.yml) 6 | [![Crates.io](https://img.shields.io/crates/v/thoth-cli.svg)](https://crates.io/crates/thoth-cli) 7 | [![Docs.rs](https://docs.rs/thoth-cli/badge.svg)](https://docs.rs/thoth-cli) 8 | 9 | ![thoth](https://github.com/user-attachments/assets/b5954aac-b20f-4c24-af7c-67dc6224df89) 10 | 11 | ![thoth_code](https://github.com/user-attachments/assets/58277dbb-42fd-4c88-9fa9-3851dff01460) 12 | 13 | A terminal scratchpad inspired by [Heynote](https://github.com/heyman/heynote). 14 | 15 | ## Inspiration 16 | As mentioned on Heynote's GitHub page, it is "a dedicated scratchpad for developers. It functions as a large persistent text buffer where you can write down anything you like." 17 | This app is truly great, and I've quite enjoyed using it as dedicated scratchpad. However, I work in the terminal, and I was finding several workflows 18 | in which having a terminal based scratchpad would be nice. Enter Thoth! Thoth follows the same design philosophy as Heynote in terms of its simplicity, but with some key differences. 19 | 20 | ### Differences Compared to Heynote 21 | - The persistent buffer in Thoth gets saved as a markdown file in your home directory. I did this since I use [Obsidian](https://obsidian.md) for all of my notes, and I wanted my scratchpad 22 | to also be displayable in the app. 23 | - Blocks are titled and can be selected by title. 24 | - The ability to edit a block using your favorite terminal editor such as Neovim, Helix, Vim, and others via setting the `$EDITOR` or `$VISUAL` environment variables. 25 | - A CLI that allows one to pipe information via STDIN into a new block, or use STDOUT to get text out of a block. 26 | 27 | 28 | 29 | ## Why the name Thoth? 30 | 31 | image 32 | 33 | In Egyptology, Thoth is the god of writing, wisdom, and magic. Thoth was in charge of messenging and recording knowledge and events for the Egyptian deities. 34 | Seems like a fitting name for a persistent scratchpad :). 35 | 36 | ## Main tools used to build Thoth 37 | - [tui-textarea](https://github.com/rhysd/tui-textarea) 38 | - [ratatui](https://github.com/ratatui-org/ratatui) 39 | - [crossterm](https://github.com/crossterm-rs/crossterm) 40 | - [pulldown-cmark](https://github.com/pulldown-cmark/pulldown-cmark) 41 | - [clap](https://github.com/clap-rs/clap) 42 | 43 | ## Installation 44 | ### Homebrew 45 | ```bash 46 | brew tap jooaf/homebrew-thoth 47 | brew install thoth 48 | ``` 49 | 50 | ### Cargo 51 | If you have `cargo` installed on your machine, you can download directly from crates.io 52 | 53 | ```bash 54 | cargo install thoth-cli --locked 55 | ``` 56 | 57 | ### Arch Linux 58 | You can install [from the AUR](https://aur.archlinux.org/packages/thoth) using an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like so: 59 | 60 | ```bash 61 | paru -S thoth 62 | ``` 63 | 64 | ### Debian/Ubuntu 65 | You can install the `.deb` for your platform by doing the following (note: the URL is an example. To get the latest release please look at the [Releases](https://github.com/jooaf/thoth/releases) section) 66 | 67 | ```bash 68 | # x86-64 69 | curl -LO https://github.com/jooaf/thoth/releases/download/v0.1.30/thoth_0.1.30_linux_amd64.deb 70 | sudo dpkg -i thoth_0.1.30_linux_amd64.deb 71 | 72 | # ARM64 73 | curl -LO https://github.com/jooaf/thoth/releases/download/v0.1.30/thoth_0.1.30_linux_arm64.deb 74 | sudo dpkg -i thoth_0.1.30_linux_arm64.deb 75 | ``` 76 | 77 | ### Fedora/CentOS 78 | You can install the `.rpm` for your platform by doing the following (note: the URL is an example. To get the latest release please look at the [Releases](https://github.com/jooaf/thoth/releases) section) 79 | 80 | ```bash 81 | # x86-64 82 | https://github.com/jooaf/thoth/releases/download/v0.1.30/thoth_0.1.30_linux_amd64.rpm 83 | sudo rpm -i thoth_0.1.30_linux_amd64.rpm 84 | 85 | # ARM64 86 | curl -LO https://github.com/jooaf/thoth/releases/download/v0.1.30/thoth_0.1.30_linux_arm64.rpm 87 | sudo dpkg -i thoth_0.1.30_linux_arm64.rpm 88 | ``` 89 | 90 | ### From binaries via Release section 91 | Go to the [releases](https://github.com/jooaf/thoth/releases) page, and download the `.tar.gz` associated with your OS (Note: currently supports Linux and MacOS). 92 | Once you have downloaded the zip file, go to the location where you downloaded the file, uncompress, and give the binary executable permissions by doing the following: 93 | ```bash 94 | cd /path/to/binary 95 | chmod +x thoth 96 | ``` 97 | You can then move binary if you choose so. Finally, add the path of the binary to your `$PATH`. 98 | 99 | ### From Source 100 | To build from source, please make sure that you have the Rust compiler installed (version 1.70.0 or higher), and the Cargo package manager. You can install both by installing [rustup](https://rustup.rs/). 101 | 102 | Once you have installed Rust, please do the following: 103 | ```bash 104 | git clone https://github.com/jooaf/thoth.git 105 | cd thoth 106 | cargo build --release 107 | ``` 108 | The binary lives in `thoth/target/release/thoth`. You can add the binary to your `$PATH`. 109 | 110 | ## Usage 111 | This will show how to use the scratchpad via the CLI or the TUI. 112 | 113 | ### Theme 114 | You can set the theme via the CLI or in the TUI. 115 | ```bash 116 | # Set to light mode 117 | thoth theme light 118 | 119 | # Set to dark mode 120 | thoth theme dark 121 | 122 | # Check current theme 123 | thoth get_theme 124 | ```` 125 | #### UI Shortcuts 126 | Users can toggle between light and dark modes while using the application: 127 | - Ctrl+L: Toggle between light and dark modes 128 | 129 | #### Persistent Configuration 130 | Theme preferences are stored in ~/.config/thoth/config.toml and persist between TUI sessions. 131 | 132 | ### TUI 133 | To start the TUI, simply type `thoth`. Since it is a persistent buffer, thoth will save when you hit quit using `q`. 134 | 135 | #### Commands for main mode 136 | ``` 137 | q: Quit 138 | : Keyboard shortcuts 139 | : Add block in focus 140 | : Delete block in focus 141 | : Copy the complete block 142 | : Copy code block 143 | Enter: Edit block 144 | : Full Screen 145 | Esc: Exit 146 | : Change title of block 147 | : Select block by title 148 | : Toggle between light and dark mode 149 | : Format json 150 | : Format markdown 151 | ``` 152 | #### Commands for edit mode 153 | ``` 154 | Esc: Exit edit mode 155 | : Move cursor top 156 | : Copy highlighted selection 157 | Shift-Up Arrow or Down: Make highlighted selection by line 158 | : Copy code block 159 | : Copy the entire block 160 | : Change title of block 161 | : Toggle between light and dark mode 162 | : Select block by title 163 | : Use external editor 164 | : Bring up other commands 165 | ``` 166 | 167 | The other commands are based off of the default keybindings 168 | for editing in `tui-textarea`. 169 | 170 | | MAPPINGS | DESCRIPTIONS | 171 | |----------|--------------| 172 | | Ctrl+H, Backspace | Delete one character before cursor | 173 | | Ctrl+K | Delete from cursor until the end of line | 174 | | Ctrl+W, Alt+Backspace | Delete one word before cursor | 175 | | Alt+D, Alt+Delete | Delete one word next to cursor | 176 | | Ctrl+U | Undo | 177 | | Ctrl+R | Redo | 178 | | Ctrl+C, Copy | Copy selected text | 179 | | Ctrl+X, Cut | Cut selected text | 180 | | Ctrl+P, ↑ | Move cursor up by one line | 181 | | Ctrl+→ | Move cursor forward by word | 182 | | Ctrl+← | Move cursor backward by word | 183 | | Ctrl+↑ | Move cursor up by paragraph | 184 | | Ctrl+↓ | Move cursor down by paragraph | 185 | | Ctrl+E, End, Ctrl+Alt+F, Ctrl+Alt+→ | Move cursor to the end of line | 186 | | Ctrl+A, Home, Ctrl+Alt+B, Ctrl+Alt+← | Move cursor to the head of line | 187 | | Ctrl+K | Format markdown block | 188 | | Ctrl+J | Format JSON | 189 | 190 | If you would like to use your external editor -- such as NeoVim, Helix, etc. -- Thoth offers that functionality. 191 | Please set your `$EDITOR` or `$VISUAL` environment variables to make use of this feature. 192 | 193 | ### CLI 194 | For accessing the CLI, one can use `thoth` followed by a command. 195 | ``` 196 | A terminal scratchpad akin to Heynote 197 | 198 | Usage: thoth [COMMAND] 199 | 200 | Commands: 201 | add Add a new block to the scratchpad 202 | list List all of the blocks within your thoth scratchpad 203 | load_backup Load backup file as the main thoth markdown file 204 | read_clipboard Read the contents of the clipboard backup file 205 | delete Delete a block by name 206 | view View (STDOUT) the contents of the block by name 207 | copy Copy the contents of a block to the system clipboard 208 | theme Set the theme to light or dark mode 209 | get_theme Get the current theme 210 | help Print this message or the help of the given subcommand(s) 211 | 212 | Options: 213 | -h, --help Print help 214 | -V, --version Print version 215 | ``` 216 | 217 | #### Examples 218 | ```nu 219 | # For adding new blocks 220 | thoth add hello_world "Hello, World!"; 221 | # For adding new blocks with content from STDIN 222 | echo "Hello, World (from STDIN)" | thoth add hello_world_stdin; 223 | # Using view to pipe contents into another command 224 | thoth view hello_world_stdin | cat 225 | ``` 226 | 227 | ### Clipboard Fallback for Wayland Users 228 | 229 | When using Thoth in Wayland environments or over SSH, the system clipboard functionality may not be available. In these cases, when you use Ctrl+Y to copy content, Thoth will: 230 | 231 | 1. Save the content to a backup file in your home directory 232 | 2. Display a message with the location of the backup file 233 | 3. Provide instructions to access the content 234 | 235 | You can retrieve the content using: 236 | 237 | ```bash 238 | thoth read_clipboard 239 | ``` 240 | 241 | This ensures your content is always accessible, even when the system clipboard is unavailable. 242 | 243 | ## Contributions 244 | Contributions are always welcomed :) !!! Please take a look at this [doc](https://github.com/jooaf/thoth/blob/main/CONTRIBUTING.md) for more information. 245 | 246 | ## TODO 247 | - Inlcude light mode: Done! 248 | - Automatically saving backup `thoth_notes.md` files: Done! 249 | - Add fuzzy finder for selecting blocks in TUI: Done! 250 | -------------------------------------------------------------------------------- /scripts/completions/thoth.nu: -------------------------------------------------------------------------------- 1 | module completions { 2 | 3 | def "nu-complete thoth view" [] { 4 | ^thoth list 5 | | lines 6 | | parse "{value}" 7 | | each { |item| 8 | if ($item.value | str contains " ") { 9 | $'"($item.value)"' 10 | } else { 11 | $item.value 12 | } 13 | } 14 | } 15 | 16 | def "nu-complete thoth delete" [] { 17 | ^thoth list 18 | | lines 19 | | parse "{value}" 20 | | each { |item| 21 | if ($item.value | str contains " ") { 22 | $'"($item.value)"' 23 | } else { 24 | $item.value 25 | } 26 | } 27 | } 28 | 29 | def "nu-complete thoth copy" [] { 30 | ^thoth list 31 | | lines 32 | | parse "{value}" 33 | | each { |item| 34 | if ($item.value | str contains " ") { 35 | $'"($item.value)"' 36 | } else { 37 | $item.value 38 | } 39 | } 40 | } 41 | export extern "thoth view" [ 42 | name: string@"nu-complete thoth view" 43 | ] 44 | export extern "thoth delete" [ 45 | name: string@"nu-complete thoth delete" 46 | ] 47 | export extern "thoth copy" [ 48 | name: string@"nu-complete thoth copy" 49 | ] 50 | } 51 | 52 | 53 | export use completions * 54 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | get_save_backup_file_path, load_textareas, save_textareas, EditorClipboard, ThemeMode, 3 | ThothConfig, 4 | }; 5 | use anyhow::{bail, Result}; 6 | use std::{ 7 | fs::File, 8 | io::{BufRead, BufReader, Write}, 9 | }; 10 | 11 | use std::env; 12 | 13 | use clap::{Parser, Subcommand}; 14 | 15 | use crate::get_save_file_path; 16 | #[derive(Parser)] 17 | #[command(author = env!("CARGO_PKG_AUTHORS"), version = env!("CARGO_PKG_VERSION"), about, long_about = None, rename_all = "snake_case")] 18 | pub struct Cli { 19 | #[command(subcommand)] 20 | pub command: Option, 21 | } 22 | 23 | #[derive(Subcommand)] 24 | #[command(rename_all = "snake_case")] 25 | pub enum Commands { 26 | /// Add a new block to the scratchpad 27 | Add { 28 | /// Name of the block to be added 29 | name: String, 30 | /// Contents to be associated with the named block 31 | content: Option, 32 | }, 33 | /// List all of the blocks within your thoth scratchpad 34 | List, 35 | /// Load backup file as the main thoth markdown file 36 | LoadBackup, 37 | /// Read the contents of the clipboard backup file 38 | ReadClipboard, 39 | /// Delete a block by name 40 | Delete { 41 | /// The name of the block to be deleted 42 | name: String, 43 | }, 44 | /// View (STDOUT) the contents of the block by name 45 | View { 46 | /// The name of the block to be used 47 | name: String, 48 | }, 49 | /// Copy the contents of a block to the system clipboard 50 | Copy { 51 | /// The name of the block to be used 52 | name: String, 53 | }, 54 | /// Set the theme to light or dark mode 55 | Theme { 56 | /// The theme to set: 'light' or 'dark' 57 | mode: String, 58 | }, 59 | /// Get the current theme 60 | GetTheme, 61 | } 62 | 63 | pub fn set_theme(mode: &str) -> Result<()> { 64 | let mode_lowercase = mode.to_lowercase(); 65 | 66 | let mut config = ThothConfig::load()?; 67 | 68 | match mode_lowercase.as_str() { 69 | "light" => { 70 | config.set_theme(ThemeMode::Light)?; 71 | println!("Theme set to light mode"); 72 | } 73 | "dark" => { 74 | config.set_theme(ThemeMode::Dark)?; 75 | println!("Theme set to dark mode"); 76 | } 77 | _ => { 78 | bail!("Invalid theme mode. Use 'light' or 'dark'"); 79 | } 80 | } 81 | 82 | Ok(()) 83 | } 84 | 85 | pub fn get_theme() -> Result<()> { 86 | let config = ThothConfig::load()?; 87 | 88 | match config.theme { 89 | ThemeMode::Light => println!("Current theme: light"), 90 | ThemeMode::Dark => println!("Current theme: dark"), 91 | } 92 | 93 | Ok(()) 94 | } 95 | 96 | pub fn read_clipboard_backup() -> Result<()> { 97 | let file_path = crate::get_clipboard_backup_file_path(); 98 | if !file_path.exists() { 99 | println!("No clipboard backup file found at {}", file_path.display()); 100 | return Ok(()); 101 | } 102 | 103 | let content = std::fs::read_to_string(&file_path)?; 104 | if content.is_empty() { 105 | println!("Clipboard backup file exists but is empty."); 106 | } else { 107 | println!("{}", content); 108 | } 109 | Ok(()) 110 | } 111 | 112 | pub fn add_block(name: &str, content: &str) -> Result<()> { 113 | let mut file = std::fs::OpenOptions::new() 114 | .append(true) 115 | .create(true) 116 | .open(get_save_file_path())?; 117 | 118 | writeln!(file, "# {}", name)?; 119 | writeln!(file, "{}", content)?; 120 | writeln!(file)?; 121 | 122 | println!("Block '{}' added successfully.", name); 123 | Ok(()) 124 | } 125 | 126 | pub fn list_blocks() -> Result<()> { 127 | let file = File::open(get_save_file_path())?; 128 | let reader = BufReader::new(file); 129 | 130 | for line in reader.lines() { 131 | let line = line?; 132 | 133 | if let Some(strip) = line.strip_prefix("# ") { 134 | println!("{}", strip); 135 | } 136 | } 137 | 138 | Ok(()) 139 | } 140 | 141 | pub fn replace_from_backup() -> Result<()> { 142 | let (backup_textareas, backup_textareas_titles) = load_textareas(get_save_backup_file_path())?; 143 | save_textareas( 144 | &backup_textareas, 145 | &backup_textareas_titles, 146 | get_save_file_path(), 147 | ) 148 | } 149 | 150 | pub fn view_block(name: &str) -> Result<()> { 151 | let file = File::open(get_save_file_path())?; 152 | let reader = BufReader::new(file); 153 | let mut blocks = Vec::new(); 154 | let mut current_block = Vec::new(); 155 | let mut current_name = String::new(); 156 | 157 | for line in reader.lines() { 158 | let line = line?; 159 | if let Some(strip) = line.strip_prefix("# ") { 160 | if !current_name.is_empty() { 161 | blocks.push((current_name, current_block)); 162 | current_block = Vec::new(); 163 | } 164 | current_name = strip.to_string(); 165 | } else { 166 | current_block.push(line); 167 | } 168 | } 169 | 170 | if !current_name.is_empty() { 171 | blocks.push((current_name, current_block)); 172 | } 173 | 174 | for (block_name, block_content) in blocks { 175 | if block_name == name { 176 | for line in block_content { 177 | println!("{}", line); 178 | } 179 | } 180 | } 181 | Ok(()) 182 | } 183 | 184 | pub fn copy_block(name: &str) -> Result<()> { 185 | let file = File::open(get_save_file_path())?; 186 | let reader = BufReader::new(file); 187 | let mut blocks = Vec::new(); 188 | let mut current_block = Vec::new(); 189 | let mut current_name = String::new(); 190 | let mut matched_name: Option = None; 191 | 192 | for line in reader.lines() { 193 | let line = line?; 194 | if let Some(strip) = line.strip_prefix("# ") { 195 | if !current_name.is_empty() { 196 | blocks.push((current_name, current_block)); 197 | current_block = Vec::new(); 198 | } 199 | current_name = strip.to_string(); 200 | } else { 201 | current_block.push(line); 202 | } 203 | } 204 | 205 | if !current_name.is_empty() { 206 | blocks.push((current_name, current_block)); 207 | } 208 | 209 | for (block_name, block_content) in blocks { 210 | if block_name == name { 211 | let result_ctx = EditorClipboard::new(); 212 | 213 | if result_ctx.is_err() { 214 | bail!("Failed to create clipboard context for copy block"); 215 | } 216 | 217 | let mut ctx = result_ctx.unwrap(); 218 | 219 | let is_success = ctx.set_contents(block_content.join("\n")); 220 | 221 | if is_success.is_err() { 222 | bail!(format!( 223 | "Failed to copy contents of block {} to system clipboard", 224 | block_name 225 | )); 226 | } 227 | matched_name = Some(block_name); 228 | break; 229 | } 230 | } 231 | match matched_name { 232 | Some(name) => println!("Successfully copied contents from block {}", name), 233 | None => println!("Didn't find the block. Please try again. You can use `thoth list` to find the name of all blocks") 234 | }; 235 | 236 | Ok(()) 237 | } 238 | 239 | pub fn delete_block(name: &str) -> Result<()> { 240 | let file = File::open(get_save_file_path())?; 241 | let reader = BufReader::new(file); 242 | let mut blocks = Vec::new(); 243 | let mut current_block = Vec::new(); 244 | let mut current_name = String::new(); 245 | 246 | for line in reader.lines() { 247 | let line = line?; 248 | if let Some(strip) = line.strip_prefix("# ") { 249 | if !current_name.is_empty() { 250 | blocks.push((current_name, current_block)); 251 | current_block = Vec::new(); 252 | } 253 | current_name = strip.to_string(); 254 | } else { 255 | current_block.push(line); 256 | } 257 | } 258 | 259 | if !current_name.is_empty() { 260 | blocks.push((current_name, current_block)); 261 | } 262 | 263 | let mut file = File::create(get_save_file_path())?; 264 | let mut deleted = false; 265 | 266 | for (block_name, block_content) in blocks { 267 | if block_name != name { 268 | writeln!(file, "# {}", block_name)?; 269 | for line in block_content { 270 | writeln!(file, "{}", line)?; 271 | } 272 | writeln!(file)?; 273 | } else { 274 | deleted = true; 275 | } 276 | } 277 | 278 | if deleted { 279 | println!("Block '{}' deleted successfully.", name); 280 | } else { 281 | println!("Block '{}' not found.", name); 282 | } 283 | 284 | Ok(()) 285 | } 286 | -------------------------------------------------------------------------------- /src/clipboard.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use arboard::{Clipboard, Error}; 3 | use std::env; 4 | use std::process; 5 | use std::sync::{Arc, Mutex}; 6 | 7 | #[cfg(target_os = "linux")] 8 | use arboard::SetExtLinux; 9 | 10 | use crate::DAEMONIZE_ARG; 11 | 12 | pub trait ClipboardTrait { 13 | fn set_contents(&mut self, content: String) -> anyhow::Result<()>; 14 | fn get_content(&self) -> anyhow::Result; 15 | } 16 | 17 | pub struct EditorClipboard { 18 | clipboard: Arc>, 19 | } 20 | 21 | impl EditorClipboard { 22 | pub fn new() -> Result { 23 | Clipboard::new().map(|c| EditorClipboard { 24 | clipboard: Arc::new(Mutex::new(c)), 25 | }) 26 | } 27 | 28 | pub fn try_new() -> Option { 29 | Self::new().ok() 30 | } 31 | 32 | pub fn set_contents(&mut self, content: String) -> Result<(), Error> { 33 | #[cfg(target_os = "linux")] 34 | { 35 | let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok() 36 | || std::env::var("XDG_SESSION_TYPE") 37 | .map(|v| v == "wayland") 38 | .unwrap_or(false); 39 | 40 | if is_wayland { 41 | let mut clipboard = self 42 | .clipboard 43 | .lock() 44 | .map_err(|_e| arboard::Error::ContentNotAvailable)?; 45 | 46 | let result = clipboard.set().wait().text(content); 47 | result 48 | } else if env::args().nth(1).as_deref() == Some(DAEMONIZE_ARG) { 49 | let mut clipboard = self 50 | .clipboard 51 | .lock() 52 | .map_err(|_e| arboard::Error::ContentNotAvailable)?; 53 | clipboard.set().wait().text(content) 54 | } else { 55 | if std::env::var("THOTH_DEBUG_CLIPBOARD").is_ok() { 56 | return Err(arboard::Error::ContentNotAvailable); 57 | } 58 | 59 | process::Command::new(env::current_exe().unwrap()) 60 | .arg(DAEMONIZE_ARG) 61 | .arg(content) 62 | .stdin(process::Stdio::null()) 63 | .stdout(process::Stdio::null()) 64 | .stderr(process::Stdio::null()) 65 | .current_dir("/") 66 | .spawn() 67 | .map_err(|_e| arboard::Error::ContentNotAvailable)?; 68 | Ok(()) 69 | } 70 | } 71 | 72 | #[cfg(not(target_os = "linux"))] 73 | { 74 | let mut clipboard = self 75 | .clipboard 76 | .lock() 77 | .map_err(|_e| arboard::Error::ContentNotAvailable)?; 78 | clipboard.set_text(content) 79 | } 80 | } 81 | 82 | pub fn get_content(&mut self) -> Result { 83 | let mut clipboard = self.clipboard.lock().unwrap(); 84 | clipboard.get_text() 85 | } 86 | 87 | #[cfg(target_os = "linux")] 88 | pub fn handle_daemon_args() -> Result<(), Error> { 89 | if let Some(content) = env::args().nth(2) { 90 | if env::args().nth(1).as_deref() == Some(DAEMONIZE_ARG) { 91 | let mut clipboard = Self::new()?; 92 | clipboard.set_contents(content)?; 93 | std::process::exit(0); 94 | } 95 | } 96 | Ok(()) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/code_block_popup.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone)] 2 | pub struct CodeBlockPopup { 3 | pub code_blocks: Vec, 4 | pub filtered_blocks: Vec, 5 | pub selected_index: usize, 6 | pub visible: bool, 7 | pub scroll_offset: usize, 8 | } 9 | 10 | #[derive(Clone)] 11 | pub struct CodeBlock { 12 | pub content: String, 13 | pub language: String, 14 | pub start_line: usize, 15 | pub end_line: usize, 16 | } 17 | 18 | impl CodeBlock { 19 | pub fn new(content: String, language: String, start_line: usize, end_line: usize) -> Self { 20 | Self { 21 | content, 22 | language, 23 | start_line, 24 | end_line, 25 | } 26 | } 27 | } 28 | 29 | impl Default for CodeBlockPopup { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | impl CodeBlockPopup { 36 | pub fn new() -> Self { 37 | CodeBlockPopup { 38 | code_blocks: Vec::new(), 39 | filtered_blocks: Vec::new(), 40 | selected_index: 0, 41 | visible: false, 42 | scroll_offset: 0, 43 | } 44 | } 45 | 46 | pub fn set_code_blocks(&mut self, code_blocks: Vec) { 47 | self.code_blocks = code_blocks; 48 | self.filtered_blocks = self.code_blocks.clone(); 49 | self.selected_index = 0; 50 | self.scroll_offset = 0; 51 | } 52 | 53 | pub fn move_selection_up(&mut self, visible_items: usize) { 54 | if self.filtered_blocks.is_empty() { 55 | return; 56 | } 57 | 58 | if self.selected_index > 0 { 59 | self.selected_index -= 1; 60 | } else { 61 | self.selected_index = self.filtered_blocks.len() - 1; 62 | } 63 | 64 | if self.selected_index <= self.scroll_offset { 65 | self.scroll_offset = self.selected_index; 66 | } 67 | if self.selected_index == self.filtered_blocks.len() - 1 { 68 | self.scroll_offset = self.filtered_blocks.len().saturating_sub(visible_items); 69 | } 70 | } 71 | 72 | pub fn move_selection_down(&mut self, visible_items: usize) { 73 | if self.filtered_blocks.is_empty() { 74 | return; 75 | } 76 | 77 | if self.selected_index < self.filtered_blocks.len() - 1 { 78 | self.selected_index += 1; 79 | } else { 80 | self.selected_index = 0; 81 | self.scroll_offset = 0; 82 | } 83 | 84 | let max_scroll = self.filtered_blocks.len().saturating_sub(visible_items); 85 | if self.selected_index >= self.scroll_offset + visible_items { 86 | self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items); 87 | if self.scroll_offset > max_scroll { 88 | self.scroll_offset = max_scroll; 89 | } 90 | } 91 | } 92 | } 93 | 94 | #[cfg(test)] 95 | mod tests { 96 | use super::*; 97 | 98 | #[test] 99 | fn test_new_code_block_popup() { 100 | let popup = CodeBlockPopup::new(); 101 | assert!(popup.code_blocks.is_empty()); 102 | assert_eq!(popup.selected_index, 0); 103 | assert!(!popup.visible); 104 | } 105 | 106 | #[test] 107 | fn test_set_code_blocks() { 108 | let mut popup = CodeBlockPopup::new(); 109 | let blocks = vec![ 110 | CodeBlock::new("fn main() {}".to_string(), "rust".to_string(), 0, 2), 111 | CodeBlock::new("def hello(): pass".to_string(), "python".to_string(), 3, 5), 112 | ]; 113 | popup.set_code_blocks(blocks); 114 | assert_eq!(popup.code_blocks.len(), 2); 115 | assert_eq!(popup.code_blocks[0].language, "rust"); 116 | assert_eq!(popup.code_blocks[1].language, "python"); 117 | } 118 | 119 | #[test] 120 | fn test_move_selection() { 121 | let mut popup = CodeBlockPopup::new(); 122 | let blocks = vec![ 123 | CodeBlock::new("Block 1".to_string(), "rust".to_string(), 0, 2), 124 | CodeBlock::new("Block 2".to_string(), "python".to_string(), 3, 5), 125 | CodeBlock::new("Block 3".to_string(), "js".to_string(), 6, 8), 126 | ]; 127 | popup.set_code_blocks(blocks); 128 | 129 | popup.move_selection_down(2); 130 | assert_eq!(popup.selected_index, 1); 131 | 132 | popup.move_selection_up(2); 133 | assert_eq!(popup.selected_index, 0); 134 | 135 | popup.move_selection_up(2); 136 | assert_eq!(popup.selected_index, 2); 137 | 138 | popup.move_selection_down(2); 139 | assert_eq!(popup.selected_index, 0); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use dirs::home_dir; 3 | use serde::{Deserialize, Serialize}; 4 | use std::fs; 5 | use std::path::PathBuf; 6 | 7 | use crate::ThemeColors; 8 | use crate::DARK_MODE_COLORS; 9 | use crate::LIGHT_MODE_COLORS; 10 | 11 | #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] 12 | pub enum ThemeMode { 13 | Light, 14 | #[default] 15 | Dark, 16 | } 17 | 18 | #[derive(Debug, Serialize, Deserialize, Default)] 19 | pub struct ThothConfig { 20 | pub theme: ThemeMode, 21 | } 22 | 23 | impl ThothConfig { 24 | pub fn load() -> Result { 25 | let config_path = get_config_path(); 26 | 27 | if !config_path.exists() { 28 | let default_config = Self::default(); 29 | default_config.save()?; 30 | return Ok(default_config); 31 | } 32 | 33 | let config_str = fs::read_to_string(config_path)?; 34 | let config: ThothConfig = toml::from_str(&config_str)?; 35 | Ok(config) 36 | } 37 | 38 | pub fn save(&self) -> Result<()> { 39 | let config_path = get_config_path(); 40 | 41 | // Create directory if it doesn't exist 42 | if let Some(parent) = config_path.parent() { 43 | if !parent.exists() { 44 | fs::create_dir_all(parent)?; 45 | } 46 | } 47 | 48 | let config_str = toml::to_string(self)?; 49 | fs::write(config_path, config_str)?; 50 | Ok(()) 51 | } 52 | 53 | pub fn set_theme(&mut self, theme: ThemeMode) -> Result<()> { 54 | self.theme = theme; 55 | self.save()?; 56 | Ok(()) 57 | } 58 | 59 | pub fn get_theme_colors(&self) -> &'static ThemeColors { 60 | match self.theme { 61 | ThemeMode::Light => &LIGHT_MODE_COLORS, 62 | ThemeMode::Dark => &DARK_MODE_COLORS, 63 | } 64 | } 65 | } 66 | 67 | pub fn get_config_path() -> PathBuf { 68 | let mut path = home_dir().unwrap_or_default(); 69 | path.push(".config"); 70 | path.push("thoth"); 71 | path.push("config.toml"); 72 | path 73 | } 74 | -------------------------------------------------------------------------------- /src/formatter.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use pulldown_cmark::{Options, Parser}; 3 | use pulldown_cmark_to_cmark::cmark; 4 | use serde_json::Value; 5 | 6 | pub fn format_markdown(input: &str) -> Result { 7 | let mut options = Options::empty(); 8 | options.insert(Options::ENABLE_TABLES); 9 | options.insert(Options::ENABLE_FOOTNOTES); 10 | options.insert(Options::ENABLE_STRIKETHROUGH); 11 | options.insert(Options::ENABLE_TASKLISTS); 12 | 13 | let parser = Parser::new_ext(input, options); 14 | let mut output = String::new(); 15 | cmark(parser, &mut output)?; 16 | Ok(output) 17 | } 18 | 19 | pub fn format_json(input: &str) -> Result { 20 | let parsed: Value = serde_json::from_str(input)?; 21 | Ok(serde_json::to_string_pretty(&parsed)?) 22 | } 23 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cli; 2 | pub mod clipboard; 3 | pub mod code_block_popup; 4 | pub mod config; 5 | pub mod formatter; 6 | pub mod markdown_renderer; 7 | pub mod scrollable_textarea; 8 | pub mod theme; 9 | pub mod title_popup; 10 | pub mod title_select_popup; 11 | pub mod ui; 12 | pub mod ui_handler; 13 | pub mod utils; 14 | 15 | pub use clipboard::ClipboardTrait; 16 | pub use clipboard::EditorClipboard; 17 | pub use code_block_popup::CodeBlockPopup; 18 | pub use config::{ThemeMode, ThothConfig}; 19 | use dirs::home_dir; 20 | pub use formatter::{format_json, format_markdown}; 21 | pub use markdown_renderer::MarkdownRenderer; 22 | pub use scrollable_textarea::ScrollableTextArea; 23 | use std::path::PathBuf; 24 | pub use theme::{ThemeColors, DARK_MODE_COLORS, LIGHT_MODE_COLORS}; 25 | pub use title_popup::TitlePopup; 26 | pub use title_select_popup::TitleSelectPopup; 27 | pub use utils::{load_textareas, save_textareas}; 28 | 29 | pub fn get_save_file_path() -> PathBuf { 30 | home_dir().unwrap_or_default().join("thoth_notes.md") 31 | } 32 | pub fn get_save_backup_file_path() -> PathBuf { 33 | home_dir().unwrap_or_default().join("thoth_notes_backup.md") 34 | } 35 | pub fn get_clipboard_backup_file_path() -> PathBuf { 36 | home_dir().unwrap_or_default().join("thoth_clipboard.txt") 37 | } 38 | 39 | // The ORANGE constant is kept for backward compatibility 40 | pub const ORANGE: ratatui::style::Color = ratatui::style::Color::Rgb(255, 165, 0); 41 | pub const DAEMONIZE_ARG: &str = "__thoth_copy_daemonize"; 42 | pub const MIN_TEXTAREA_HEIGHT: usize = 3; 43 | pub const BORDER_PADDING_SIZE: usize = 2; 44 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use clap::Parser; 3 | use crossterm::{ 4 | event::{self, DisableMouseCapture, EnableMouseCapture, Event}, 5 | execute, 6 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 7 | }; 8 | use ratatui::{backend::CrosstermBackend, Terminal}; 9 | use std::{ 10 | io::{self, Read}, 11 | thread, 12 | }; 13 | use thoth_cli::{ 14 | cli::{ 15 | add_block, copy_block, delete_block, get_theme, list_blocks, read_clipboard_backup, 16 | replace_from_backup, set_theme, view_block, 17 | }, 18 | get_save_backup_file_path, EditorClipboard, 19 | }; 20 | use thoth_cli::{ 21 | cli::{Cli, Commands}, 22 | ui_handler::{draw_ui, handle_input, UIState}, 23 | utils::save_textareas, 24 | }; 25 | 26 | use std::time::Duration; 27 | 28 | fn main() -> Result<()> { 29 | #[cfg(target_os = "linux")] 30 | EditorClipboard::handle_daemon_args()?; 31 | let cli = Cli::parse(); 32 | 33 | match &cli.command { 34 | Some(Commands::Add { name, content }) => { 35 | let content = match content { 36 | Some(c) => c.to_string(), 37 | None => { 38 | let mut buffer = String::new(); 39 | if atty::is(atty::Stream::Stdin) { 40 | bail!(format!("Couldn't create '{}' because nothing was passed in. Either pipe in contents or use `thoth add {} `", name, name)); 41 | } 42 | io::stdin().read_to_string(&mut buffer)?; 43 | if buffer.trim().is_empty() { 44 | bail!(format!("Couldn't create '{}' because nothing was passed in. Either pipe in contents or use `thoth add {} `", name, name)); 45 | } 46 | buffer 47 | } 48 | }; 49 | add_block(name, &content)?; 50 | } 51 | Some(Commands::List) => { 52 | list_blocks()?; 53 | } 54 | Some(Commands::ReadClipboard) => { 55 | read_clipboard_backup()?; 56 | } 57 | Some(Commands::LoadBackup) => { 58 | replace_from_backup()?; 59 | } 60 | Some(Commands::Delete { name }) => { 61 | delete_block(name)?; 62 | } 63 | Some(Commands::View { name }) => { 64 | view_block(name)?; 65 | } 66 | Some(Commands::Copy { name }) => { 67 | copy_block(name)?; 68 | } 69 | Some(Commands::Theme { mode }) => { 70 | set_theme(mode)?; 71 | } 72 | Some(Commands::GetTheme) => { 73 | get_theme()?; 74 | } 75 | None => { 76 | run_ui()?; 77 | } 78 | } 79 | 80 | Ok(()) 81 | } 82 | 83 | pub fn run_ui() -> Result<()> { 84 | enable_raw_mode()?; 85 | let mut stdout = io::stdout(); 86 | execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; 87 | let backend = CrosstermBackend::new(stdout); 88 | let mut terminal = Terminal::new(backend)?; 89 | 90 | let mut state = UIState::new()?; 91 | 92 | let draw_interval = Duration::from_millis(33); 93 | 94 | let copy_textareas = state.scrollable_textarea.textareas.clone(); 95 | let copy_titles = state.scrollable_textarea.titles.clone(); 96 | thread::spawn(move || loop { 97 | let _ = save_textareas(©_textareas, ©_titles, get_save_backup_file_path()); 98 | thread::sleep(Duration::from_secs(60)); // save backup every minute 99 | }); 100 | 101 | loop { 102 | let should_draw = state.last_draw.elapsed() >= draw_interval; 103 | if should_draw { 104 | draw_ui(&mut terminal, &mut state)?; 105 | state.last_draw = std::time::Instant::now(); 106 | } 107 | 108 | if event::poll(Duration::from_millis(1))? { 109 | if let Event::Key(key) = event::read()? { 110 | if handle_input(&mut terminal, &mut state, key)? { 111 | break; 112 | } 113 | } 114 | } 115 | } 116 | 117 | disable_raw_mode()?; 118 | execute!( 119 | terminal.backend_mut(), 120 | LeaveAlternateScreen, 121 | DisableMouseCapture 122 | )?; 123 | Ok(()) 124 | } 125 | -------------------------------------------------------------------------------- /src/markdown_renderer.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{anyhow, Result}; 4 | use ratatui::{ 5 | style::{Color, Modifier, Style}, 6 | text::{Line, Span, Text}, 7 | }; 8 | use syntect::{ 9 | easy::HighlightLines, 10 | highlighting::{Style as SyntectStyle, ThemeSet}, 11 | parsing::{SyntaxReference, SyntaxSet}, 12 | }; 13 | 14 | use crate::ThemeMode; 15 | 16 | pub struct MarkdownRenderer { 17 | syntax_set: SyntaxSet, 18 | theme_set: ThemeSet, 19 | theme: String, 20 | cache: HashMap>, 21 | } 22 | const HEADER_COLORS: [Color; 6] = [ 23 | Color::Red, 24 | Color::Green, 25 | Color::Yellow, 26 | Color::Blue, 27 | Color::Magenta, 28 | Color::Cyan, 29 | ]; 30 | 31 | impl Default for MarkdownRenderer { 32 | fn default() -> Self { 33 | Self::new(&ThemeMode::Dark) 34 | } 35 | } 36 | 37 | impl MarkdownRenderer { 38 | pub fn new(theme_mode: &ThemeMode) -> Self { 39 | let theme_name = match theme_mode { 40 | ThemeMode::Light => "base16-ocean.light", 41 | ThemeMode::Dark => "base16-mocha.dark", 42 | }; 43 | 44 | MarkdownRenderer { 45 | syntax_set: SyntaxSet::load_defaults_newlines(), 46 | theme_set: ThemeSet::load_defaults(), 47 | theme: theme_name.to_string(), 48 | cache: HashMap::new(), 49 | } 50 | } 51 | 52 | pub fn set_theme(&mut self, theme_mode: &ThemeMode) { 53 | let theme_name = match theme_mode { 54 | ThemeMode::Light => "base16-ocean.light", 55 | ThemeMode::Dark => "base16-mocha.dark", 56 | }; 57 | self.theme = theme_name.to_string(); 58 | self.cache.clear(); 59 | } 60 | 61 | pub fn render_markdown( 62 | &mut self, 63 | markdown: String, 64 | title: String, 65 | width: usize, 66 | ) -> Result> { 67 | if let Some(lines) = self.cache.get(&format!("{}{}", &title, &markdown)) { 68 | return Ok(lines.clone()); 69 | } 70 | 71 | let md_syntax = self.syntax_set.find_syntax_by_extension("md").unwrap(); 72 | let mut lines = Vec::new(); 73 | let mut in_code_block = false; 74 | let mut code_block_lang = String::new(); 75 | let mut code_block_content = Vec::new(); 76 | let theme = &self.theme_set.themes[&self.theme]; 77 | let mut h = HighlightLines::new(md_syntax, theme); 78 | 79 | if self.is_json_document(&markdown) { 80 | let json_syntax = self.syntax_set.find_syntax_by_extension("json").unwrap(); 81 | return Ok(Text::from(self.highlight_code_block( 82 | &markdown.lines().map(|x| x.to_string()).collect::>(), 83 | "json", 84 | json_syntax, 85 | theme, 86 | width, 87 | )?)); 88 | } 89 | 90 | let mut markdown_lines = markdown.lines().map(|x| x.to_string()).peekable(); 91 | 92 | while let Some(line) = markdown_lines.next() { 93 | // Code block handling 94 | if line.starts_with("```") { 95 | if in_code_block { 96 | // End of code block 97 | lines.extend(self.process_code_block_end( 98 | &code_block_content, 99 | &code_block_lang, 100 | md_syntax, 101 | theme, 102 | width, 103 | )?); 104 | code_block_content.clear(); 105 | in_code_block = false; 106 | } else { 107 | // Start of code block 108 | in_code_block = true; 109 | code_block_lang = line.trim_start_matches('`').to_string(); 110 | 111 | // Check if it's a one-line code block 112 | if let Some(next_line) = markdown_lines.peek() { 113 | if next_line.starts_with("```") { 114 | lines.extend(self.process_empty_code_block( 115 | &code_block_lang, 116 | md_syntax, 117 | theme, 118 | width, 119 | )?); 120 | in_code_block = false; 121 | markdown_lines.next(); // Skip the closing ``` 122 | continue; 123 | } 124 | } 125 | } 126 | } else if in_code_block { 127 | code_block_content.push(line.to_string()); 128 | } else { 129 | let processed_line = self.process_markdown_line(&line, &mut h, theme, width)?; 130 | lines.push(processed_line); 131 | } 132 | } 133 | 134 | let markdown_lines = Text::from(lines); 135 | let new_key = &format!("{}{}", &title, &markdown); 136 | self.cache.insert(new_key.clone(), markdown_lines.clone()); 137 | Ok(markdown_lines) 138 | } 139 | 140 | fn is_json_document(&self, content: &str) -> bool { 141 | let trimmed = content.trim(); 142 | (trimmed.starts_with('{') || trimmed.starts_with('[')) 143 | && (trimmed.ends_with('}') || trimmed.ends_with(']')) 144 | } 145 | 146 | fn process_code_block_end( 147 | &self, 148 | code_content: &[String], 149 | lang: &str, 150 | default_syntax: &SyntaxReference, 151 | theme: &syntect::highlighting::Theme, 152 | width: usize, 153 | ) -> Result>> { 154 | let lang = lang.trim_start_matches('`').trim(); 155 | let syntax = if !lang.is_empty() { 156 | self.syntax_set 157 | .find_syntax_by_token(lang) 158 | .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) 159 | .unwrap_or(default_syntax) 160 | } else { 161 | default_syntax 162 | }; 163 | 164 | self.highlight_code_block(code_content, lang, syntax, theme, width) 165 | } 166 | 167 | fn process_empty_code_block( 168 | &self, 169 | lang: &str, 170 | default_syntax: &SyntaxReference, 171 | theme: &syntect::highlighting::Theme, 172 | width: usize, 173 | ) -> Result>> { 174 | let lang = lang.trim(); 175 | let syntax = if !lang.is_empty() { 176 | self.syntax_set 177 | .find_syntax_by_token(lang) 178 | .or_else(|| self.syntax_set.find_syntax_by_extension(lang)) 179 | .unwrap_or(default_syntax) 180 | } else { 181 | default_syntax 182 | }; 183 | 184 | self.highlight_code_block(&["".to_string()], lang, syntax, theme, width) 185 | } 186 | 187 | fn highlight_code_block( 188 | &self, 189 | code: &[String], 190 | lang: &str, 191 | syntax: &SyntaxReference, 192 | theme: &syntect::highlighting::Theme, 193 | width: usize, 194 | ) -> Result>> { 195 | let mut h = HighlightLines::new(syntax, theme); 196 | let mut result = Vec::new(); 197 | 198 | let max_line_num = code.len(); 199 | let line_num_width = max_line_num.to_string().len().max(1); 200 | 201 | let lang_name = lang.trim(); 202 | let header_text = if !lang_name.is_empty() { 203 | format!("▌ {} ", lang_name) 204 | } else { 205 | "▌ code ".to_string() 206 | }; 207 | 208 | let border_width = width.saturating_sub(header_text.len()); 209 | let header = Span::styled( 210 | format!("{}{}", header_text, "─".repeat(border_width)), 211 | Style::default() 212 | .fg(Color::White) 213 | .add_modifier(Modifier::BOLD), 214 | ); 215 | 216 | if lang != "json" { 217 | result.push(Line::from(vec![header])); 218 | } 219 | 220 | for (line_number, line) in code.iter().enumerate() { 221 | let highlighted = h 222 | .highlight_line(line, &self.syntax_set) 223 | .map_err(|e| anyhow!("Highlight error: {}", e))?; 224 | 225 | let mut spans = if lang == "json" { 226 | vec![Span::styled( 227 | format!("{:>width$} ", line_number + 1, width = line_num_width), 228 | Style::default().fg(Color::DarkGray), 229 | )] 230 | } else { 231 | vec![Span::styled( 232 | format!("{:>width$} │ ", line_number + 1, width = line_num_width), 233 | Style::default().fg(Color::DarkGray), 234 | )] 235 | }; 236 | spans.extend(self.process_syntect_highlights(highlighted)); 237 | 238 | let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); 239 | let padding_width = width.saturating_sub(line_content.len()); 240 | if padding_width > 0 { 241 | spans.push(Span::styled(" ".repeat(padding_width), Style::default())); 242 | } 243 | 244 | result.push(Line::from(spans)); 245 | } 246 | 247 | if lang != "json" { 248 | result.push(Line::from(Span::styled( 249 | "─".repeat(width), 250 | Style::default().fg(Color::DarkGray), 251 | ))); 252 | } 253 | 254 | Ok(result) 255 | } 256 | 257 | fn process_markdown_line( 258 | &self, 259 | line: &str, 260 | h: &mut HighlightLines, 261 | _theme: &syntect::highlighting::Theme, 262 | width: usize, 263 | ) -> Result> { 264 | let mut spans: Vec>; 265 | 266 | // Handle header 267 | if let Some((is_header, level)) = self.is_header(line) { 268 | if is_header { 269 | let header_color = if level <= 6 { 270 | HEADER_COLORS[level.saturating_sub(1)] 271 | } else { 272 | HEADER_COLORS[0] 273 | }; 274 | 275 | spans = vec![Span::styled( 276 | line.to_string(), 277 | Style::default() 278 | .fg(header_color) 279 | .add_modifier(Modifier::BOLD), 280 | )]; 281 | return Ok(Line::from(spans)); 282 | } 283 | } 284 | 285 | let (content, is_blockquote) = self.process_blockquote(line); 286 | 287 | if let Some((content, is_checked)) = self.is_checkbox_list_item(&content) { 288 | return self.format_checkbox_item(line, content, is_checked, h, width); 289 | } 290 | 291 | let (content, is_list, is_ordered, order_num) = self.process_list_item(&content); 292 | 293 | let highlighted = h 294 | .highlight_line(&content, &self.syntax_set) 295 | .map_err(|e| anyhow!("Highlight error: {}", e))?; 296 | 297 | spans = self.process_syntect_highlights(highlighted); 298 | 299 | if is_blockquote { 300 | spans = self.apply_blockquote_styling(spans); 301 | } 302 | 303 | if is_list { 304 | spans = self.apply_list_styling(line, spans, is_ordered, order_num); 305 | } else { 306 | let whitespace_prefix = line 307 | .chars() 308 | .take_while(|c| c.is_whitespace()) 309 | .collect::(); 310 | 311 | if !whitespace_prefix.is_empty() { 312 | spans.insert(0, Span::styled(whitespace_prefix, Style::default())); 313 | } 314 | } 315 | 316 | let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); 317 | let padding_width = width.saturating_sub(line_content.len()); 318 | if padding_width > 0 { 319 | spans.push(Span::styled(" ".repeat(padding_width), Style::default())); 320 | } 321 | 322 | Ok(Line::from(spans)) 323 | } 324 | 325 | fn is_header(&self, line: &str) -> Option<(bool, usize)> { 326 | if let Some(header_level) = line.bytes().position(|b| b != b'#') { 327 | if header_level > 0 328 | && header_level <= 6 329 | && line.as_bytes().get(header_level) == Some(&b' ') 330 | { 331 | return Some((true, header_level)); 332 | } 333 | } 334 | None 335 | } 336 | 337 | fn process_blockquote(&self, line: &str) -> (String, bool) { 338 | if line.starts_with('>') { 339 | let content = line.trim_start_matches('>').trim_start().to_string(); 340 | (content, true) 341 | } else { 342 | (line.to_string(), false) 343 | } 344 | } 345 | 346 | fn is_checkbox_list_item(&self, line: &str) -> Option<(String, bool)> { 347 | let trimmed = line.trim_start(); 348 | 349 | if trimmed.starts_with("- [ ]") 350 | || trimmed.starts_with("+ [ ]") 351 | || trimmed.starts_with("* [ ]") 352 | { 353 | let content = trimmed[5..].to_string(); 354 | return Some((content, false)); // Unchecked 355 | } else if trimmed.starts_with("- [x]") 356 | || trimmed.starts_with("- [X]") 357 | || trimmed.starts_with("+ [x]") 358 | || trimmed.starts_with("+ [X]") 359 | || trimmed.starts_with("* [x]") 360 | || trimmed.starts_with("* [X]") 361 | { 362 | let content = trimmed[5..].to_string(); 363 | return Some((content, true)); // Checked 364 | } 365 | 366 | // Also match "- [ x ]" or "- [ ]" style with extra spaces 367 | if let Some(list_marker_pos) = ["- [", "+ [", "* ["].iter().find_map(|marker| { 368 | if trimmed.starts_with(marker) { 369 | Some(marker.len()) 370 | } else { 371 | None 372 | } 373 | }) { 374 | if trimmed.len() > list_marker_pos { 375 | let remaining = &trimmed[list_marker_pos..]; 376 | if remaining.starts_with(" ]") || remaining.starts_with(" ]") { 377 | let content_start = remaining 378 | .find(']') 379 | .map(|pos| list_marker_pos + pos + 1) 380 | .unwrap_or(list_marker_pos); 381 | 382 | if content_start < trimmed.len() { 383 | let content = trimmed[content_start + 1..].to_string(); 384 | return Some((content, false)); 385 | } 386 | } else if remaining.starts_with(" x ]") 387 | || remaining.starts_with(" X ]") 388 | || remaining.starts_with("x ]") 389 | || remaining.starts_with("X ]") 390 | { 391 | let content_start = remaining 392 | .find(']') 393 | .map(|pos| list_marker_pos + pos + 1) 394 | .unwrap_or(list_marker_pos); 395 | 396 | if content_start < trimmed.len() { 397 | let content = trimmed[content_start + 1..].to_string(); 398 | return Some((content, true)); 399 | } 400 | } 401 | } 402 | } 403 | 404 | None 405 | } 406 | 407 | fn format_checkbox_item( 408 | &self, 409 | line: &str, 410 | content: String, 411 | is_checked: bool, 412 | h: &mut HighlightLines, 413 | width: usize, 414 | ) -> Result> { 415 | let whitespace_prefix = line 416 | .chars() 417 | .take_while(|c| c.is_whitespace()) 418 | .collect::(); 419 | 420 | let checkbox = if is_checked { 421 | Span::styled("[X] ".to_string(), Style::default().fg(Color::Green)) 422 | } else { 423 | Span::styled("[ ] ".to_string(), Style::default().fg(Color::Gray)) 424 | }; 425 | 426 | let highlighted = h 427 | .highlight_line(&content, &self.syntax_set) 428 | .map_err(|e| anyhow!("Highlight error: {}", e))?; 429 | 430 | let mut content_spans = self.process_syntect_highlights(highlighted); 431 | 432 | let mut spans = vec![Span::styled(whitespace_prefix, Style::default()), checkbox]; 433 | spans.append(&mut content_spans); 434 | 435 | let line_content: String = spans.iter().map(|span| span.content.clone()).collect(); 436 | let padding_width = width.saturating_sub(line_content.len()); 437 | if padding_width > 0 { 438 | spans.push(Span::styled(" ".repeat(padding_width), Style::default())); 439 | } 440 | 441 | Ok(Line::from(spans)) 442 | } 443 | 444 | fn process_list_item(&self, line: &str) -> (String, bool, bool, usize) { 445 | let trimmed = line.trim_start(); 446 | 447 | if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") { 448 | let content = trimmed[2..].to_string(); 449 | return (content, true, false, 0); 450 | } 451 | 452 | if let Some(dot_pos) = trimmed.find(". ") { 453 | if dot_pos > 0 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit()) { 454 | let order_num = trimmed[..dot_pos].parse::().unwrap_or(1); 455 | let content = trimmed[(dot_pos + 2)..].to_string(); 456 | return (content, true, true, order_num); 457 | } 458 | } 459 | 460 | (line.to_string(), false, false, 0) 461 | } 462 | 463 | fn apply_blockquote_styling<'a>(&self, spans: Vec>) -> Vec> { 464 | let mut result = vec![Span::styled( 465 | "▎ ".to_string(), 466 | Style::default().fg(Color::Blue), 467 | )]; 468 | 469 | for span in spans { 470 | result.push(Span::styled(span.content, Style::default().fg(Color::Gray))); 471 | } 472 | 473 | result 474 | } 475 | 476 | fn apply_list_styling<'a>( 477 | &self, 478 | original_line: &str, 479 | spans: Vec>, 480 | is_ordered: bool, 481 | order_num: usize, 482 | ) -> Vec> { 483 | let whitespace_prefix = original_line 484 | .chars() 485 | .take_while(|c| c.is_whitespace()) 486 | .collect::(); 487 | 488 | let list_marker = if is_ordered { 489 | format!("{}. ", order_num) 490 | } else { 491 | "• ".to_string() 492 | }; 493 | 494 | let prefix = Span::styled( 495 | format!("{}{}", whitespace_prefix, list_marker), 496 | Style::default().fg(Color::Yellow), 497 | ); 498 | 499 | let mut result = vec![prefix]; 500 | result.extend(spans); 501 | result 502 | } 503 | 504 | fn process_syntect_highlights( 505 | &self, 506 | highlighted: Vec<(SyntectStyle, &str)>, 507 | ) -> Vec> { 508 | let mut spans = Vec::new(); 509 | 510 | for (style, text) in highlighted { 511 | let text_owned = text.to_string(); 512 | 513 | if text_owned.contains("~~") && text_owned.matches("~~").count() >= 2 { 514 | self.process_strikethrough(&text_owned, style, &mut spans); 515 | continue; 516 | } 517 | 518 | if text_owned.contains('`') && !text_owned.contains("```") { 519 | self.process_inline_code(&text_owned, style, &mut spans); 520 | continue; 521 | } 522 | 523 | if text_owned.contains('[') 524 | && text_owned.contains(']') 525 | && text_owned.contains('(') 526 | && text_owned.contains(')') 527 | { 528 | self.process_links(&text_owned, style, &mut spans); 529 | continue; 530 | } 531 | 532 | spans.push(Span::styled( 533 | text_owned, 534 | syntect_style_to_ratatui_style(style), 535 | )); 536 | } 537 | 538 | spans 539 | } 540 | 541 | fn process_strikethrough( 542 | &self, 543 | text: &str, 544 | style: SyntectStyle, 545 | spans: &mut Vec>, 546 | ) { 547 | let parts: Vec<&str> = text.split("~~").collect(); 548 | let mut in_strikethrough = false; 549 | 550 | for (i, part) in parts.iter().enumerate() { 551 | if !part.is_empty() { 552 | if in_strikethrough { 553 | spans.push(Span::styled( 554 | part.to_string(), 555 | syntect_style_to_ratatui_style(style).add_modifier(Modifier::CROSSED_OUT), 556 | )); 557 | } else { 558 | spans.push(Span::styled( 559 | part.to_string(), 560 | syntect_style_to_ratatui_style(style), 561 | )); 562 | } 563 | } 564 | 565 | if i < parts.len() - 1 { 566 | in_strikethrough = !in_strikethrough; 567 | } 568 | } 569 | } 570 | 571 | fn process_inline_code(&self, text: &str, style: SyntectStyle, spans: &mut Vec>) { 572 | let parts: Vec<&str> = text.split('`').collect(); 573 | let mut in_code = false; 574 | 575 | for (i, part) in parts.iter().enumerate() { 576 | if !part.is_empty() { 577 | if in_code { 578 | spans.push(Span::styled( 579 | part.to_string(), 580 | Style::default().fg(Color::White).bg(Color::DarkGray), 581 | )); 582 | } else { 583 | spans.push(Span::styled( 584 | part.to_string(), 585 | syntect_style_to_ratatui_style(style), 586 | )); 587 | } 588 | } 589 | 590 | if i < parts.len() - 1 { 591 | in_code = !in_code; 592 | } 593 | } 594 | } 595 | 596 | fn process_links(&self, text: &str, style: SyntectStyle, spans: &mut Vec>) { 597 | let mut in_link = false; 598 | let mut in_url = false; 599 | let mut current_text = String::new(); 600 | let mut link_text = String::new(); 601 | 602 | let mut i = 0; 603 | let chars: Vec = text.chars().collect(); 604 | 605 | while i < chars.len() { 606 | match chars[i] { 607 | '[' => { 608 | if !in_link && !in_url { 609 | // Add any text before the link 610 | if !current_text.is_empty() { 611 | spans.push(Span::styled( 612 | current_text.clone(), 613 | syntect_style_to_ratatui_style(style), 614 | )); 615 | current_text.clear(); 616 | } 617 | in_link = true; 618 | } else { 619 | current_text.push('['); 620 | } 621 | } 622 | ']' => { 623 | if in_link && !in_url { 624 | link_text = current_text.clone(); 625 | current_text.clear(); 626 | in_link = false; 627 | 628 | // Check if next char is '(' 629 | if i + 1 < chars.len() && chars[i + 1] == '(' { 630 | in_url = true; 631 | i += 1; // Skip the opening paren 632 | } else { 633 | // Not a proper link, just show the text with brackets 634 | spans.push(Span::styled( 635 | format!("[{}]", link_text), 636 | syntect_style_to_ratatui_style(style), 637 | )); 638 | link_text.clear(); 639 | } 640 | } else { 641 | current_text.push(']'); 642 | } 643 | } 644 | ')' => { 645 | if in_url { 646 | // URL part is in current_text, link text is in link_text 647 | in_url = false; 648 | 649 | spans.push(Span::styled( 650 | link_text.clone(), 651 | Style::default() 652 | .fg(Color::Cyan) 653 | .add_modifier(Modifier::UNDERLINED), 654 | )); 655 | 656 | link_text.clear(); 657 | current_text.clear(); 658 | } else { 659 | current_text.push(')'); 660 | } 661 | } 662 | _ => { 663 | current_text.push(chars[i]); 664 | } 665 | } 666 | 667 | i += 1; 668 | } 669 | 670 | if !current_text.is_empty() { 671 | spans.push(Span::styled( 672 | current_text, 673 | syntect_style_to_ratatui_style(style), 674 | )); 675 | } 676 | } 677 | } 678 | 679 | fn syntect_style_to_ratatui_style(style: SyntectStyle) -> Style { 680 | let mut ratatui_style = Style::default().fg(Color::Rgb( 681 | style.foreground.r, 682 | style.foreground.g, 683 | style.foreground.b, 684 | )); 685 | 686 | if style 687 | .font_style 688 | .contains(syntect::highlighting::FontStyle::BOLD) 689 | { 690 | ratatui_style = ratatui_style.add_modifier(Modifier::BOLD); 691 | } 692 | if style 693 | .font_style 694 | .contains(syntect::highlighting::FontStyle::ITALIC) 695 | { 696 | ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC); 697 | } 698 | if style 699 | .font_style 700 | .contains(syntect::highlighting::FontStyle::UNDERLINE) 701 | { 702 | ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED); 703 | } 704 | 705 | ratatui_style 706 | } 707 | 708 | #[cfg(test)] 709 | mod tests { 710 | use crate::MIN_TEXTAREA_HEIGHT; 711 | 712 | use super::*; 713 | 714 | #[test] 715 | fn test_render_markdown() { 716 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 717 | let markdown = "# Header\n\nThis is **bold** and *italic* text."; 718 | let rendered = renderer 719 | .render_markdown(markdown.to_string(), "".to_string(), 40) 720 | .unwrap(); 721 | 722 | assert!(rendered.lines.len() >= MIN_TEXTAREA_HEIGHT); 723 | assert!(rendered.lines[0] 724 | .spans 725 | .iter() 726 | .any(|span| span.content.contains("Header"))); 727 | assert!(rendered.lines[2] 728 | .spans 729 | .iter() 730 | .any(|span| span.content.contains("This is"))); 731 | } 732 | 733 | #[test] 734 | fn test_render_markdown_with_code_block() { 735 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 736 | let markdown = "# Header\n\n```rust\nfn main() {\n println!(\"Hello, world!\");\n}\n```"; 737 | 738 | let rendered = renderer 739 | .render_markdown(markdown.to_string(), "".to_string(), 40) 740 | .unwrap(); 741 | assert!(rendered.lines.len() > 5); 742 | assert!(rendered.lines[0] 743 | .spans 744 | .iter() 745 | .any(|span| span.content.contains("Header"))); 746 | assert!(rendered 747 | .lines 748 | .iter() 749 | .any(|line| line.spans.iter().any(|span| span.content.contains("main")))); 750 | } 751 | 752 | #[test] 753 | fn test_render_json() { 754 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 755 | let json = r#"{ 756 | "name": "John Doe", 757 | "age": 30, 758 | "city": "New &ThemeMode::DarkYork" 759 | }"#; 760 | 761 | let rendered = renderer 762 | .render_markdown(json.to_string(), "".to_string(), 40) 763 | .unwrap(); 764 | 765 | assert!(rendered.lines.len() == 5); 766 | assert!(rendered.lines[0] 767 | .spans 768 | .iter() 769 | .any(|span| span.content.contains("{"))); 770 | assert!(rendered.lines[4] 771 | .spans 772 | .iter() 773 | .any(|span| span.content.contains("}"))); 774 | } 775 | 776 | #[test] 777 | fn test_render_markdown_with_lists() { 778 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 779 | let markdown = 780 | "# List Test\n\n- Item 1\n- Item 2\n - Nested item\n\n1. First item\n2. Second item"; 781 | let rendered = renderer 782 | .render_markdown(markdown.to_string(), "".to_string(), 40) 783 | .unwrap(); 784 | 785 | assert!(rendered 786 | .lines 787 | .iter() 788 | .any(|line| line.spans.iter().any(|span| span.content.contains("•")))); 789 | assert!(rendered 790 | .lines 791 | .iter() 792 | .any(|line| line.spans.iter().any(|span| span.content.contains("1.")))); 793 | } 794 | 795 | #[test] 796 | fn test_render_markdown_with_links() { 797 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 798 | let markdown = "Visit [Google](https://google.com) for search"; 799 | let rendered = renderer 800 | .render_markdown(markdown.to_string(), "".to_string(), 40) 801 | .unwrap(); 802 | 803 | assert!(rendered.lines.iter().any(|line| line 804 | .spans 805 | .iter() 806 | .any(|span| span.content.contains("Google")))); 807 | } 808 | 809 | #[test] 810 | fn test_render_markdown_with_blockquotes() { 811 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 812 | let markdown = "> This is a blockquote\n> Another line"; 813 | let rendered = renderer 814 | .render_markdown(markdown.to_string(), "".to_string(), 40) 815 | .unwrap(); 816 | 817 | assert!(rendered 818 | .lines 819 | .iter() 820 | .any(|line| line.spans.iter().any(|span| span.content.contains("▎")))); 821 | } 822 | 823 | #[test] 824 | fn test_render_markdown_with_task_lists() { 825 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 826 | let markdown = "- [ ] Unchecked task\n- [x] Checked task\n- [ x ] Also checked task\n- [ ] Another unchecked task"; 827 | let rendered = renderer 828 | .render_markdown(markdown.to_string(), "".to_string(), 40) 829 | .unwrap(); 830 | 831 | assert!(rendered 832 | .lines 833 | .iter() 834 | .any(|line| line.spans.iter().any(|span| span.content.contains("[ ]")))); 835 | assert!(rendered 836 | .lines 837 | .iter() 838 | .any(|line| line.spans.iter().any(|span| span.content.contains("[X]")))); 839 | } 840 | 841 | #[test] 842 | fn test_render_markdown_with_inline_code() { 843 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 844 | let markdown = "Some `inline code` here"; 845 | let rendered = renderer 846 | .render_markdown(markdown.to_string(), "".to_string(), 40) 847 | .unwrap(); 848 | 849 | assert!(rendered.lines.iter().any(|line| line 850 | .spans 851 | .iter() 852 | .any(|span| span.content.contains("inline code")))); 853 | } 854 | 855 | #[test] 856 | fn test_render_markdown_with_strikethrough() { 857 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 858 | let markdown = "This is ~~strikethrough~~ text"; 859 | let rendered = renderer 860 | .render_markdown(markdown.to_string(), "".to_string(), 40) 861 | .unwrap(); 862 | 863 | let has_strikethrough = rendered.lines.iter().any(|line| { 864 | line.spans.iter().any(|span| { 865 | let modifiers = span.style.add_modifier; 866 | return modifiers.contains(Modifier::CROSSED_OUT); 867 | }) 868 | }); 869 | 870 | assert!(has_strikethrough); 871 | } 872 | 873 | #[test] 874 | fn test_render_markdown_with_one_line_code_block() { 875 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 876 | let markdown = "# Header\n\n```rust\n```\n\nText after.".to_string(); 877 | let rendered = renderer 878 | .render_markdown(markdown, "".to_string(), 40) 879 | .unwrap(); 880 | 881 | assert!(rendered.lines.len() > MIN_TEXTAREA_HEIGHT); 882 | assert!(rendered.lines[0] 883 | .spans 884 | .iter() 885 | .any(|span| span.content.contains("Header"))); 886 | assert!(rendered 887 | .lines 888 | .iter() 889 | .any(|line| line.spans.iter().any(|span| span.content.contains("1 │")))); 890 | assert!(rendered 891 | .lines 892 | .last() 893 | .unwrap() 894 | .spans 895 | .iter() 896 | .any(|span| span.content.contains("Text after."))); 897 | } 898 | 899 | #[test] 900 | fn test_indentation_preservation() { 901 | let mut renderer = MarkdownRenderer::new(&ThemeMode::Dark); 902 | let markdown = "Regular text\n Indented text\n Double indented text"; 903 | let rendered = renderer 904 | .render_markdown(markdown.to_string(), "".to_string(), 50) 905 | .unwrap(); 906 | 907 | assert_eq!(rendered.lines.len(), 3); 908 | 909 | assert!(rendered.lines[1] 910 | .spans 911 | .iter() 912 | .any(|span| span.content.starts_with(" "))); 913 | 914 | assert!(rendered.lines[2] 915 | .spans 916 | .iter() 917 | .any(|span| span.content.starts_with(" "))); 918 | } 919 | } 920 | -------------------------------------------------------------------------------- /src/scrollable_textarea.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | cell::RefCell, 3 | cmp::{max, min}, 4 | collections::HashMap, 5 | rc::Rc, 6 | }; 7 | 8 | use crate::{EditorClipboard, ThemeColors, BORDER_PADDING_SIZE, MIN_TEXTAREA_HEIGHT}; 9 | use crate::{MarkdownRenderer, ThemeMode}; 10 | use anyhow; 11 | use anyhow::Result; 12 | use rand::Rng; 13 | use ratatui::{ 14 | layout::{Constraint, Direction, Layout, Rect}, 15 | style::Style, 16 | text::Text, 17 | widgets::{Block, Borders, Paragraph, Wrap}, 18 | Frame, 19 | }; 20 | use std::collections::HashSet; 21 | use tui_textarea::TextArea; 22 | 23 | const RENDER_CACHE_SIZE: usize = 100; 24 | 25 | struct MarkdownCache { 26 | cache: HashMap>, 27 | renderer: MarkdownRenderer, 28 | current_theme: ThemeMode, 29 | } 30 | 31 | impl MarkdownCache { 32 | fn new(theme_mode: &ThemeMode) -> Self { 33 | MarkdownCache { 34 | cache: HashMap::with_capacity(RENDER_CACHE_SIZE), 35 | renderer: MarkdownRenderer::new(theme_mode), 36 | current_theme: theme_mode.clone(), 37 | } 38 | } 39 | 40 | fn update_theme(&mut self, theme_mode: &ThemeMode) { 41 | if self.current_theme != *theme_mode { 42 | self.renderer.set_theme(theme_mode); 43 | self.current_theme = theme_mode.clone(); 44 | self.cache.clear(); 45 | } 46 | } 47 | 48 | fn get_or_render( 49 | &mut self, 50 | content: &str, 51 | title: &str, 52 | width: usize, 53 | theme_mode: &ThemeMode, 54 | ) -> Result> { 55 | self.update_theme(theme_mode); 56 | 57 | let cache_key = format!("{}:{}:{:?}", title, content, theme_mode); 58 | if let Some(cached) = self.cache.get(&cache_key) { 59 | return Ok(cached.clone()); 60 | } 61 | 62 | let content = format!("{}\n", content); 63 | 64 | let rendered = self 65 | .renderer 66 | .render_markdown(content, title.to_string(), width)?; 67 | 68 | if self.cache.len() >= RENDER_CACHE_SIZE { 69 | if let Some(old_key) = self.cache.keys().next().cloned() { 70 | self.cache.remove(&old_key); 71 | } 72 | } 73 | 74 | self.cache.insert(cache_key, rendered.clone()); 75 | Ok(rendered) 76 | } 77 | } 78 | 79 | pub struct ScrollableTextArea { 80 | pub textareas: Vec>, 81 | pub titles: Vec, 82 | pub scroll: usize, 83 | pub focused_index: usize, 84 | pub edit_mode: bool, 85 | pub full_screen_mode: bool, 86 | pub viewport_height: u16, 87 | pub start_sel: usize, 88 | markdown_cache: Rc>, 89 | } 90 | 91 | impl Default for ScrollableTextArea { 92 | fn default() -> Self { 93 | Self::new() 94 | } 95 | } 96 | 97 | impl ScrollableTextArea { 98 | pub fn new() -> Self { 99 | ScrollableTextArea { 100 | textareas: Vec::with_capacity(10), 101 | titles: Vec::with_capacity(10), 102 | scroll: 0, 103 | focused_index: 0, 104 | edit_mode: false, 105 | full_screen_mode: false, 106 | viewport_height: 0, 107 | start_sel: 0, 108 | markdown_cache: Rc::new(RefCell::new(MarkdownCache::new(&ThemeMode::Dark))), 109 | } 110 | } 111 | 112 | pub fn toggle_full_screen(&mut self) { 113 | self.full_screen_mode = !self.full_screen_mode; 114 | if self.full_screen_mode { 115 | self.edit_mode = false; 116 | self.scroll = 0 117 | } 118 | } 119 | 120 | pub fn change_title(&mut self, new_title: String) { 121 | let unique_title = self.generate_unique_title(new_title); 122 | if self.focused_index < self.titles.len() { 123 | self.titles[self.focused_index] = unique_title; 124 | } 125 | } 126 | 127 | fn generate_unique_title(&self, base_title: String) -> String { 128 | if !self.titles.contains(&base_title) { 129 | return base_title; 130 | } 131 | 132 | let existing_titles: HashSet = self.titles.iter().cloned().collect(); 133 | let mut rng = rand::thread_rng(); 134 | let mut new_title = base_title.clone(); 135 | let mut counter = 1; 136 | 137 | while existing_titles.contains(&new_title) { 138 | if counter <= 5 { 139 | new_title = format!("{} {}", base_title, counter); 140 | } else { 141 | new_title = format!("{} {}", base_title, rng.gen_range(100..1000)); 142 | } 143 | counter += 1; 144 | } 145 | 146 | new_title 147 | } 148 | 149 | pub fn add_textarea(&mut self, textarea: TextArea<'static>, title: String) { 150 | let new_index = if self.textareas.is_empty() { 151 | 0 152 | } else { 153 | self.focused_index + 1 154 | }; 155 | 156 | let unique_title = self.generate_unique_title(title); 157 | self.textareas.insert(new_index, textarea); 158 | self.titles.insert(new_index, unique_title); 159 | self.focused_index = new_index; 160 | self.adjust_scroll_to_focused(); 161 | } 162 | 163 | pub fn copy_textarea_contents(&self) -> Result<()> { 164 | if let Some(textarea) = self.textareas.get(self.focused_index) { 165 | let content = textarea.lines().join("\n"); 166 | let mut ctx = EditorClipboard::new() 167 | .map_err(|e| anyhow::anyhow!("Failed to create clipboard context: {}", e))?; 168 | ctx.set_contents(content) 169 | .map_err(|e| anyhow::anyhow!("Failed to set clipboard contents: {}", e))?; 170 | } 171 | Ok(()) 172 | } 173 | 174 | pub fn jump_to_textarea(&mut self, index: usize) { 175 | if index < self.textareas.len() { 176 | self.focused_index = index; 177 | self.adjust_scroll_to_focused(); 178 | } 179 | } 180 | 181 | pub fn remove_textarea(&mut self, index: usize) { 182 | if index < self.textareas.len() { 183 | self.textareas.remove(index); 184 | self.titles.remove(index); 185 | if self.focused_index >= self.textareas.len() { 186 | self.focused_index = self.textareas.len().saturating_sub(1); 187 | } 188 | self.scroll = self.scroll.min(self.focused_index); 189 | } 190 | } 191 | 192 | pub fn move_focus(&mut self, direction: isize) { 193 | let new_index = self.focused_index as isize + direction; 194 | if new_index >= (self.textareas.len()) as isize { 195 | self.focused_index = 0; 196 | } else if new_index < 0 { 197 | self.focused_index = self.textareas.len() - 1; 198 | } else { 199 | self.focused_index = new_index as usize; 200 | } 201 | self.adjust_scroll_to_focused(); 202 | } 203 | 204 | pub fn adjust_scroll_to_focused(&mut self) { 205 | if self.focused_index < self.scroll { 206 | self.scroll = self.focused_index; 207 | } else { 208 | let mut height_sum = 0; 209 | for i in self.scroll..=self.focused_index { 210 | let textarea_height = 211 | self.textareas[i].lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE; 212 | height_sum += textarea_height; 213 | 214 | if height_sum > self.viewport_height as usize { 215 | self.scroll = i; 216 | break; 217 | } 218 | } 219 | } 220 | 221 | while self.calculate_height_to_focused() > self.viewport_height 222 | && self.scroll < self.focused_index 223 | { 224 | self.scroll += 1; 225 | } 226 | } 227 | 228 | pub fn calculate_height_to_focused(&self) -> u16 { 229 | self.textareas[self.scroll..=self.focused_index] 230 | .iter() 231 | .map(|ta| (ta.lines().len().max(MIN_TEXTAREA_HEIGHT) + BORDER_PADDING_SIZE) as u16) 232 | .sum() 233 | } 234 | 235 | pub fn initialize_scroll(&mut self) { 236 | self.scroll = 0; 237 | self.focused_index = 0; 238 | } 239 | 240 | pub fn copy_focused_textarea_contents(&self) -> anyhow::Result<()> { 241 | use std::fs::File; 242 | use std::io::Write; 243 | 244 | if let Some(textarea) = self.textareas.get(self.focused_index) { 245 | let content = textarea.lines().join("\n"); 246 | 247 | // Force clipboard failure if env var is set (for testing) 248 | if std::env::var("THOTH_TEST_CLIPBOARD_FAIL").is_ok() { 249 | let backup_path = crate::get_clipboard_backup_file_path(); 250 | let mut file = File::create(&backup_path)?; 251 | file.write_all(content.as_bytes())?; 252 | 253 | return Err(anyhow::anyhow!( 254 | "TESTING: Simulated clipboard failure.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 255 | backup_path.display() 256 | )); 257 | } 258 | 259 | match EditorClipboard::new() { 260 | Ok(mut ctx) => { 261 | if let Err(e) = ctx.set_contents(content.clone()) { 262 | let is_wayland = std::env::var("WAYLAND_DISPLAY").is_ok() 263 | || std::env::var("XDG_SESSION_TYPE") 264 | .map(|v| v == "wayland") 265 | .unwrap_or(false); 266 | 267 | let backup_path = crate::get_clipboard_backup_file_path(); 268 | let mut file = File::create(&backup_path)?; 269 | file.write_all(content.as_bytes())?; 270 | 271 | if is_wayland { 272 | return Err(anyhow::anyhow!( 273 | "Wayland clipboard error.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 274 | backup_path.display() 275 | )); 276 | } else { 277 | return Err(anyhow::anyhow!( 278 | "Clipboard error: {}.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 279 | e.to_string().split('\n').next().unwrap_or("Unknown error"), 280 | backup_path.display() 281 | )); 282 | } 283 | } 284 | } 285 | Err(_) => { 286 | let backup_path = crate::get_clipboard_backup_file_path(); 287 | let mut file = File::create(&backup_path)?; 288 | file.write_all(content.as_bytes())?; 289 | 290 | return Err(anyhow::anyhow!( 291 | "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 292 | backup_path.display() 293 | )); 294 | } 295 | } 296 | } 297 | Ok(()) 298 | } 299 | 300 | pub fn copy_selection_contents(&mut self) -> anyhow::Result<()> { 301 | if let Some(textarea) = self.textareas.get(self.focused_index) { 302 | let all_lines = textarea.lines(); 303 | let (cur_row, _) = textarea.cursor(); 304 | let min_row = min(cur_row, self.start_sel); 305 | let max_row = max(cur_row, self.start_sel); 306 | 307 | if max_row <= all_lines.len() { 308 | let content = all_lines[min_row..max_row].join("\n"); 309 | let mut ctx = EditorClipboard::new().unwrap(); 310 | ctx.set_contents(content).unwrap(); 311 | } 312 | } 313 | // reset selection 314 | self.start_sel = 0; 315 | Ok(()) 316 | } 317 | 318 | fn render_full_screen_edit(&mut self, f: &mut Frame, area: Rect, theme: &ThemeColors) { 319 | let textarea = &mut self.textareas[self.focused_index]; 320 | let title = &self.titles[self.focused_index]; 321 | 322 | let block = Block::default() 323 | .title(title.clone()) 324 | .borders(Borders::ALL) 325 | .border_style(Style::default().fg(theme.primary)); 326 | 327 | let edit_style = Style::default().fg(theme.foreground).bg(theme.background); 328 | let cursor_style = Style::default().fg(theme.foreground).bg(theme.accent); 329 | 330 | textarea.set_block(block); 331 | textarea.set_style(edit_style); 332 | textarea.set_cursor_style(cursor_style); 333 | textarea.set_selection_style(Style::default().bg(theme.selection)); 334 | f.render_widget(textarea.widget(), area); 335 | } 336 | 337 | pub fn render( 338 | &mut self, 339 | f: &mut Frame, 340 | area: Rect, 341 | theme: &ThemeColors, 342 | theme_mode: &ThemeMode, 343 | ) -> Result<()> { 344 | self.viewport_height = area.height; 345 | 346 | if self.full_screen_mode { 347 | if self.edit_mode { 348 | self.render_full_screen_edit(f, area, theme); 349 | } else { 350 | self.render_full_screen(f, area, theme, theme_mode)?; 351 | } 352 | } else { 353 | let mut remaining_height = area.height; 354 | let mut visible_textareas = Vec::with_capacity(self.textareas.len()); 355 | 356 | for (i, textarea) in self.textareas.iter_mut().enumerate().skip(self.scroll) { 357 | if remaining_height == 0 { 358 | break; 359 | } 360 | 361 | let content_height = (textarea.lines().len() + BORDER_PADDING_SIZE) as u16; 362 | let is_focused = i == self.focused_index; 363 | let is_editing = is_focused && self.edit_mode; 364 | 365 | let height = if is_editing { 366 | remaining_height 367 | } else { 368 | content_height 369 | .min(remaining_height) 370 | .max(MIN_TEXTAREA_HEIGHT as u16) 371 | }; 372 | 373 | visible_textareas.push((i, textarea, height)); 374 | remaining_height = remaining_height.saturating_sub(height); 375 | 376 | if is_editing { 377 | break; 378 | } 379 | } 380 | 381 | let chunks = Layout::default() 382 | .direction(Direction::Vertical) 383 | .constraints( 384 | visible_textareas 385 | .iter() 386 | .map(|(_, _, height)| Constraint::Length(*height)) 387 | .collect::>(), 388 | ) 389 | .split(area); 390 | 391 | for ((i, textarea, _), chunk) in visible_textareas.into_iter().zip(chunks.iter()) { 392 | let title = &self.titles[i]; 393 | let is_focused = i == self.focused_index; 394 | let is_editing = is_focused && self.edit_mode; 395 | 396 | let style = if is_focused { 397 | if is_editing { 398 | Style::default().fg(theme.foreground).bg(theme.background) 399 | } else { 400 | Style::default().fg(theme.background).bg(theme.selection) 401 | } 402 | } else { 403 | Style::default().fg(theme.foreground).bg(theme.background) 404 | }; 405 | 406 | let block = Block::default() 407 | .title(title.to_owned()) 408 | .borders(Borders::ALL) 409 | .border_style(Style::default().fg(theme.primary)) 410 | .style(style); 411 | 412 | if is_editing { 413 | textarea.set_block(block); 414 | textarea.set_style(style); 415 | textarea 416 | .set_cursor_style(Style::default().fg(theme.foreground).bg(theme.accent)); 417 | f.render_widget(textarea.widget(), *chunk); 418 | } else { 419 | let content = textarea.lines().join("\n"); 420 | let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render( 421 | &content, 422 | title, 423 | f.size().width as usize - BORDER_PADDING_SIZE, 424 | theme_mode, 425 | )?; 426 | let paragraph = Paragraph::new(rendered_markdown) 427 | .block(block) 428 | .wrap(Wrap { trim: false }); 429 | f.render_widget(paragraph, *chunk); 430 | } 431 | } 432 | } 433 | 434 | Ok(()) 435 | } 436 | 437 | pub fn handle_scroll(&mut self, direction: isize) { 438 | if !self.full_screen_mode { 439 | return; 440 | } 441 | 442 | let current_height = self.textareas[self.focused_index].lines().len(); 443 | let is_scrolling_down = direction > 0; 444 | let is_at_last_textarea = self.focused_index == self.textareas.len() - 1; 445 | let is_at_first_textarea = self.focused_index == 0; 446 | 447 | // Scrolling down 448 | if is_scrolling_down { 449 | let can_scroll_further = self.scroll < current_height.saturating_sub(1); 450 | let can_move_to_next = !is_at_last_textarea; 451 | 452 | if can_scroll_further { 453 | self.scroll += 1; 454 | } else if can_move_to_next { 455 | self.focused_index += 1; 456 | self.scroll = 0; 457 | } 458 | return; 459 | } 460 | 461 | // Scrolling up 462 | let can_scroll_up = self.scroll > 0; 463 | let can_move_to_previous = !is_at_first_textarea; 464 | 465 | if can_scroll_up { 466 | self.scroll -= 1; 467 | } else if can_move_to_previous { 468 | self.focused_index -= 1; 469 | let prev_height = self.textareas[self.focused_index].lines().len(); 470 | self.scroll = prev_height.saturating_sub(1); 471 | } 472 | } 473 | 474 | fn render_full_screen( 475 | &mut self, 476 | f: &mut Frame, 477 | area: Rect, 478 | theme: &ThemeColors, 479 | theme_mode: &ThemeMode, 480 | ) -> Result<()> { 481 | let textarea = &mut self.textareas[self.focused_index]; 482 | textarea.set_selection_style(Style::default().bg(theme.selection)); 483 | let title = &self.titles[self.focused_index]; 484 | 485 | let block = Block::default() 486 | .title(title.clone()) 487 | .borders(Borders::ALL) 488 | .border_style(Style::default().fg(theme.primary)); 489 | 490 | let content = textarea.lines().join("\n"); 491 | let rendered_markdown = self.markdown_cache.borrow_mut().get_or_render( 492 | &content, 493 | title, 494 | f.size().width as usize - BORDER_PADDING_SIZE, 495 | theme_mode, 496 | )?; 497 | 498 | let paragraph = Paragraph::new(rendered_markdown) 499 | .block(block) 500 | .wrap(Wrap { trim: false }) 501 | .scroll((self.scroll as u16, 0)); 502 | 503 | f.render_widget(paragraph, area); 504 | Ok(()) 505 | } 506 | pub fn test_get_clipboard_content(&self) -> String { 507 | self.textareas[self.focused_index].lines().join("\n") 508 | } 509 | } 510 | 511 | #[cfg(test)] 512 | mod tests { 513 | use super::*; 514 | 515 | fn create_test_textarea() -> ScrollableTextArea { 516 | ScrollableTextArea { 517 | textareas: Vec::new(), 518 | titles: Vec::new(), 519 | scroll: 0, 520 | focused_index: 0, 521 | edit_mode: false, 522 | full_screen_mode: false, 523 | viewport_height: 0, 524 | start_sel: 0, 525 | markdown_cache: Rc::new(RefCell::new(MarkdownCache::new(&ThemeMode::Dark))), 526 | } 527 | } 528 | 529 | #[test] 530 | fn test_add_textarea() { 531 | let mut sta = create_test_textarea(); 532 | sta.add_textarea(TextArea::default(), "Test".to_string()); 533 | assert_eq!(sta.textareas.len(), 1); 534 | assert_eq!(sta.titles.len(), 1); 535 | assert_eq!(sta.focused_index, 0); 536 | } 537 | 538 | #[test] 539 | fn test_move_focus() { 540 | let mut sta = create_test_textarea(); 541 | sta.add_textarea(TextArea::default(), "Test1".to_string()); 542 | assert_eq!(sta.focused_index, 0); 543 | sta.add_textarea(TextArea::default(), "Test2".to_string()); 544 | 545 | assert_eq!(sta.focused_index, 1); 546 | sta.move_focus(1); 547 | assert_eq!(sta.focused_index, 0); 548 | sta.move_focus(-1); 549 | assert_eq!(sta.focused_index, 1); 550 | } 551 | 552 | #[test] 553 | fn test_remove_textarea() { 554 | let mut sta = create_test_textarea(); 555 | sta.add_textarea(TextArea::default(), "Test1".to_string()); 556 | sta.add_textarea(TextArea::default(), "Test2".to_string()); 557 | sta.remove_textarea(0); 558 | assert_eq!(sta.textareas.len(), 1); 559 | assert_eq!(sta.titles.len(), 1); 560 | assert_eq!(sta.titles[0], "Test2"); 561 | } 562 | 563 | #[test] 564 | fn test_change_title() { 565 | let mut sta = create_test_textarea(); 566 | sta.add_textarea(TextArea::default(), "Test".to_string()); 567 | sta.change_title("New Title".to_string()); 568 | assert_eq!(sta.titles[0], "New Title"); 569 | } 570 | 571 | #[test] 572 | fn test_toggle_full_screen() { 573 | let mut sta = create_test_textarea(); 574 | assert!(!sta.full_screen_mode); 575 | sta.toggle_full_screen(); 576 | assert!(sta.full_screen_mode); 577 | assert!(!sta.edit_mode); 578 | } 579 | 580 | #[test] 581 | fn test_copy_textarea_contents() { 582 | let mut sta = create_test_textarea(); 583 | let mut textarea = TextArea::default(); 584 | textarea.insert_str("Test content"); 585 | sta.add_textarea(textarea, "Test".to_string()); 586 | 587 | let result = sta.copy_textarea_contents(); 588 | 589 | match result { 590 | Ok(_) => println!("Clipboard operation succeeded"), 591 | Err(e) => { 592 | let error_message = e.to_string(); 593 | assert!( 594 | error_message.contains("clipboard") || error_message.contains("display"), 595 | "Unexpected error: {}", 596 | error_message 597 | ); 598 | } 599 | } 600 | } 601 | 602 | #[test] 603 | fn test_jump_to_textarea() { 604 | let mut sta = create_test_textarea(); 605 | sta.add_textarea(TextArea::default(), "Test1".to_string()); 606 | sta.add_textarea(TextArea::default(), "Test2".to_string()); 607 | sta.jump_to_textarea(1); 608 | assert_eq!(sta.focused_index, 1); 609 | } 610 | } 611 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use ratatui::style::Color; 3 | 4 | #[derive(Clone)] 5 | pub struct ThemeColors { 6 | pub primary: Color, 7 | pub background: Color, 8 | pub foreground: Color, 9 | pub selection: Color, 10 | pub accent: Color, 11 | pub error: Color, 12 | pub success: Color, 13 | pub border: Color, 14 | pub header_bg: Color, 15 | pub header_fg: Color, 16 | } 17 | 18 | impl PartialEq for ThemeColors { 19 | fn eq(&self, other: &Self) -> bool { 20 | self.primary == other.primary 21 | && self.background == other.background 22 | && self.foreground == other.foreground 23 | && self.selection == other.selection 24 | && self.accent == other.accent 25 | && self.error == other.error 26 | && self.success == other.success 27 | && self.border == other.border 28 | && self.header_bg == other.header_bg 29 | && self.header_fg == other.header_fg 30 | } 31 | } 32 | 33 | lazy_static! { 34 | pub static ref LIGHT_MODE_COLORS: ThemeColors = ThemeColors { 35 | primary: Color::Rgb(25, 118, 210), // Blue 36 | background: Color::Rgb(248, 248, 248), // Light Grey 37 | foreground: Color::Rgb(33, 33, 33), // Dark Grey (near black) 38 | selection: Color::Rgb(207, 232, 252), // Light Blue 39 | accent: Color::Rgb(230, 81, 0), // Orange 40 | error: Color::Rgb(211, 47, 47), // Red 41 | success: Color::Rgb(56, 142, 60), // Green 42 | border: Color::Rgb(189, 189, 189), // Mid Grey 43 | header_bg: Color::Rgb(232, 232, 232), // Lighter Grey 44 | header_fg: Color::Rgb(66, 66, 66), // Dark Grey 45 | }; 46 | 47 | pub static ref DARK_MODE_COLORS: ThemeColors = ThemeColors { 48 | primary: Color::Rgb(255, 165, 0), // Orange (same as original ORANGE constant) 49 | background: Color::Black, // Black background (original) 50 | foreground: Color::White, // White text (original) 51 | selection: Color::DarkGray, // Dark gray for selection (original) 52 | accent: Color::Rgb(255, 165, 0), // Orange (same as original ORANGE constant) 53 | error: Color::Red, // Red for errors (original) 54 | success: Color::Green, // Green for success (original) 55 | border: Color::Rgb(255, 165, 0), // Orange borders (original) 56 | header_bg: Color::Black, // Black header background (original) 57 | header_fg: Color::Rgb(255, 165, 0), // Orange header text (original) 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/title_popup.rs: -------------------------------------------------------------------------------- 1 | pub struct TitlePopup { 2 | pub title: String, 3 | pub visible: bool, 4 | } 5 | 6 | impl TitlePopup { 7 | pub fn new() -> Self { 8 | TitlePopup { 9 | title: String::new(), 10 | visible: false, 11 | } 12 | } 13 | } 14 | 15 | impl Default for TitlePopup { 16 | fn default() -> Self { 17 | Self::new() 18 | } 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | 25 | #[test] 26 | fn test_new_title_popup() { 27 | let popup = TitlePopup::new(); 28 | assert_eq!(popup.title, ""); 29 | assert!(!popup.visible); 30 | } 31 | 32 | #[test] 33 | fn test_title_popup_visibility() { 34 | let mut popup = TitlePopup::new(); 35 | popup.visible = true; 36 | assert!(popup.visible); 37 | } 38 | 39 | #[test] 40 | fn test_title_popup_set_title() { 41 | let mut popup = TitlePopup::new(); 42 | popup.title = "New Title".to_string(); 43 | assert_eq!(popup.title, "New Title"); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/title_select_popup.rs: -------------------------------------------------------------------------------- 1 | use fuzzy_matcher::skim::SkimMatcherV2; 2 | use fuzzy_matcher::FuzzyMatcher; 3 | 4 | pub struct TitleSelectPopup { 5 | pub titles: Vec, 6 | pub filtered_titles: Vec, 7 | pub selected_index: usize, 8 | pub visible: bool, 9 | pub scroll_offset: usize, 10 | pub search_query: String, 11 | } 12 | 13 | pub struct TitleMatch { 14 | pub title: String, 15 | pub index: usize, 16 | pub score: i64, 17 | } 18 | 19 | impl TitleMatch { 20 | pub fn new(title: String, index: usize, score: i64) -> Self { 21 | Self { 22 | title, 23 | index, 24 | score, 25 | } 26 | } 27 | } 28 | 29 | impl Default for TitleSelectPopup { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | impl TitleSelectPopup { 36 | pub fn new() -> Self { 37 | TitleSelectPopup { 38 | titles: Vec::new(), 39 | filtered_titles: Vec::new(), 40 | selected_index: 0, 41 | visible: false, 42 | scroll_offset: 0, 43 | search_query: String::new(), 44 | } 45 | } 46 | 47 | pub fn set_titles(&mut self, titles: Vec) { 48 | self.titles = titles; 49 | self.filtered_titles = self 50 | .titles 51 | .iter() 52 | .enumerate() 53 | .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0)) 54 | .collect(); 55 | } 56 | 57 | pub fn reset_filtered_titles(&mut self) { 58 | self.filtered_titles = self 59 | .titles 60 | .iter() 61 | .enumerate() 62 | .map(|(idx, title)| TitleMatch::new(title.clone(), idx, 0)) 63 | .collect(); 64 | } 65 | 66 | pub fn move_selection_up(&mut self, visible_items: usize) { 67 | if self.filtered_titles.is_empty() { 68 | return; 69 | } 70 | 71 | if self.selected_index > 0 { 72 | self.selected_index -= 1; 73 | } else { 74 | self.selected_index = self.filtered_titles.len() - 1; 75 | } 76 | 77 | if self.selected_index <= self.scroll_offset { 78 | self.scroll_offset = self.selected_index; 79 | } 80 | if self.selected_index == self.filtered_titles.len() - 1 { 81 | self.scroll_offset = self.filtered_titles.len().saturating_sub(visible_items); 82 | } 83 | } 84 | 85 | pub fn move_selection_down(&mut self, visible_items: usize) { 86 | if self.filtered_titles.is_empty() { 87 | return; 88 | } 89 | 90 | if self.selected_index < self.filtered_titles.len() - 1 { 91 | self.selected_index += 1; 92 | } else { 93 | self.selected_index = 0; 94 | self.scroll_offset = 0; 95 | } 96 | 97 | let max_scroll = self.filtered_titles.len().saturating_sub(visible_items); 98 | if self.selected_index >= self.scroll_offset + visible_items { 99 | self.scroll_offset = (self.selected_index + 1).saturating_sub(visible_items); 100 | if self.scroll_offset > max_scroll { 101 | self.scroll_offset = max_scroll; 102 | } 103 | } 104 | } 105 | 106 | pub fn update_search(&mut self) { 107 | let matcher = SkimMatcherV2::default(); 108 | 109 | let mut matched_titles: Vec = self 110 | .titles 111 | .iter() 112 | .enumerate() 113 | .filter_map(|(idx, title)| { 114 | matcher 115 | .fuzzy_match(title, &self.search_query) 116 | .map(|score| TitleMatch::new(title.clone(), idx, score)) 117 | }) 118 | .collect(); 119 | 120 | matched_titles.sort_by(|a, b| b.score.cmp(&a.score)); 121 | 122 | self.filtered_titles = matched_titles; 123 | 124 | if !self.filtered_titles.is_empty() { 125 | self.selected_index = 0; 126 | self.scroll_offset = 0; 127 | } 128 | } 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | use super::*; 134 | 135 | #[test] 136 | fn test_new_title_select_popup() { 137 | let popup = TitleSelectPopup::new(); 138 | assert!(popup.titles.is_empty()); 139 | assert_eq!(popup.selected_index, 0); 140 | assert!(!popup.visible); 141 | } 142 | 143 | #[test] 144 | fn test_title_select_popup_add_titles() { 145 | let mut popup = TitleSelectPopup::new(); 146 | let titles = vec!["Title1".to_string(), "Title2".to_string()]; 147 | popup.set_titles(titles); 148 | assert_eq!(popup.titles.len(), 2); 149 | assert_eq!(popup.titles[0], "Title1"); 150 | assert_eq!(popup.titles[1], "Title2"); 151 | assert_eq!(popup.filtered_titles.len(), 2); 152 | } 153 | 154 | #[test] 155 | fn test_wrap_around_selection() { 156 | let mut popup = TitleSelectPopup::new(); 157 | popup.set_titles(vec!["1".to_string(), "2".to_string(), "3".to_string()]); 158 | 159 | popup.selected_index = 0; 160 | popup.move_selection_up(2); 161 | assert_eq!(popup.selected_index, 2); 162 | assert_eq!(popup.scroll_offset, 1); 163 | 164 | popup.selected_index = 2; 165 | popup.move_selection_down(2); 166 | assert_eq!(popup.selected_index, 0); 167 | assert_eq!(popup.scroll_offset, 0); 168 | } 169 | 170 | #[test] 171 | fn test_search_filtering() { 172 | let mut popup = TitleSelectPopup::new(); 173 | popup.set_titles(vec![ 174 | "Apple".to_string(), 175 | "Banana".to_string(), 176 | "Apricot".to_string(), 177 | ]); 178 | 179 | popup.search_query = "ap".to_string(); 180 | popup.update_search(); 181 | assert_eq!(popup.filtered_titles.len(), 2); 182 | assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apple")); 183 | assert!(popup.filtered_titles.iter().any(|tm| tm.title == "Apricot")); 184 | 185 | assert!(popup.filtered_titles[0].score >= popup.filtered_titles[1].score); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/ui.rs: -------------------------------------------------------------------------------- 1 | use crate::code_block_popup::CodeBlockPopup; 2 | use crate::{ThemeColors, TitlePopup, TitleSelectPopup, BORDER_PADDING_SIZE, DARK_MODE_COLORS}; 3 | use ratatui::style::Color; 4 | use ratatui::widgets::Wrap; 5 | use ratatui::{ 6 | layout::{Constraint, Direction, Layout, Rect}, 7 | style::{Modifier, Style}, 8 | text::{Line, Span}, 9 | widgets::{Block, Borders, Cell, Paragraph, Row, Table, Tabs}, 10 | Frame, 11 | }; 12 | use unicode_width::UnicodeWidthStr; 13 | 14 | pub struct EditCommandsPopup { 15 | pub visible: bool, 16 | } 17 | 18 | impl EditCommandsPopup { 19 | pub fn new() -> Self { 20 | EditCommandsPopup { visible: false } 21 | } 22 | } 23 | impl Default for EditCommandsPopup { 24 | fn default() -> Self { 25 | Self::new() 26 | } 27 | } 28 | 29 | pub struct UiPopup { 30 | pub message: String, 31 | pub popup_title: String, 32 | pub visible: bool, 33 | pub percent_x: u16, 34 | pub percent_y: u16, 35 | } 36 | 37 | impl UiPopup { 38 | pub fn new(popup_title: String, percent_x: u16, percent_y: u16) -> Self { 39 | UiPopup { 40 | message: String::new(), 41 | visible: false, 42 | popup_title, 43 | percent_x, 44 | percent_y, 45 | } 46 | } 47 | 48 | pub fn show(&mut self, message: String) { 49 | self.message = message; 50 | self.visible = true; 51 | } 52 | 53 | pub fn hide(&mut self) { 54 | self.visible = false; 55 | } 56 | } 57 | 58 | impl Default for UiPopup { 59 | fn default() -> Self { 60 | Self::new("".to_owned(), 60, 20) 61 | } 62 | } 63 | 64 | pub fn render_edit_commands_popup(f: &mut Frame, theme: &ThemeColors) { 65 | let area = centered_rect(80, 80, f.size()); 66 | f.render_widget(ratatui::widgets::Clear, area); 67 | 68 | let block = Block::default() 69 | .borders(Borders::ALL) 70 | .border_style(Style::default().fg(theme.primary)) 71 | .title("Editing Commands - Esc to exit"); 72 | 73 | let header = Row::new(vec![ 74 | Cell::from("MAPPINGS").style( 75 | Style::default() 76 | .fg(theme.accent) 77 | .add_modifier(Modifier::BOLD), 78 | ), 79 | Cell::from("DESCRIPTIONS").style( 80 | Style::default() 81 | .fg(theme.accent) 82 | .add_modifier(Modifier::BOLD), 83 | ), 84 | ]) 85 | .height(BORDER_PADDING_SIZE as u16); 86 | 87 | let commands: Vec = vec![ 88 | Row::new(vec![ 89 | "Ctrl+H, Backspace", 90 | "Delete one character before cursor", 91 | ]), 92 | Row::new(vec!["Ctrl+K", "Delete from cursor until the end of line"]), 93 | Row::new(vec![ 94 | "Ctrl+W, Alt+Backspace", 95 | "Delete one word before cursor", 96 | ]), 97 | Row::new(vec!["Alt+D, Alt+Delete", "Delete one word next to cursor"]), 98 | Row::new(vec!["Ctrl+U", "Undo"]), 99 | Row::new(vec!["Ctrl+R", "Redo"]), 100 | Row::new(vec!["Ctrl+C, Copy", "Copy selected text"]), 101 | Row::new(vec!["Ctrl+X, Cut", "Cut selected text"]), 102 | Row::new(vec!["Ctrl+P, ↑", "Move cursor up by one line"]), 103 | Row::new(vec!["Ctrl+→", "Move cursor forward by word"]), 104 | Row::new(vec!["Ctrl+←", "Move cursor backward by word"]), 105 | Row::new(vec!["Ctrl+↑", "Move cursor up by paragraph"]), 106 | Row::new(vec!["Ctrl+↓", "Move cursor down by paragraph"]), 107 | Row::new(vec![ 108 | "Ctrl+E, End, Ctrl+Alt+F, Ctrl+Alt+→", 109 | "Move cursor to the end of line", 110 | ]), 111 | Row::new(vec![ 112 | "Ctrl+A, Home, Ctrl+Alt+B, Ctrl+Alt+←", 113 | "Move cursor to the head of line", 114 | ]), 115 | Row::new(vec!["Ctrl+L", "Toggle between light and dark mode"]), 116 | Row::new(vec!["Ctrl+K", "Format markdown block"]), 117 | Row::new(vec!["Ctrl+J", "Format JSON"]), 118 | ]; 119 | 120 | let table = Table::new(commands, [Constraint::Length(5), Constraint::Length(5)]) 121 | .header(header) 122 | .block(block) 123 | .widths([Constraint::Percentage(30), Constraint::Percentage(70)]) 124 | .column_spacing(BORDER_PADDING_SIZE as u16) 125 | .highlight_style(Style::default().fg(theme.accent)) 126 | .highlight_symbol(">> "); 127 | 128 | f.render_widget(table, area); 129 | } 130 | 131 | pub fn render_header(f: &mut Frame, area: Rect, is_edit_mode: bool, theme: &ThemeColors) { 132 | let available_width = area.width as usize; 133 | let normal_commands = vec![ 134 | "q:Quit", 135 | "^h:Help", 136 | "^n:Add", 137 | "^d:Del", 138 | "^y:Copy", 139 | "^c:Copy Code", 140 | "^v:Paste", 141 | "Enter:Edit", 142 | "^f:Focus", 143 | "Esc:Exit", 144 | "^t:Title", 145 | "^s:Select", 146 | "^l:Toggle Theme", 147 | "^j:Format JSON", 148 | "^k:Format Markdown", 149 | ]; 150 | let edit_commands = vec![ 151 | "Esc:Exit Edit", 152 | "^g:Move Cursor Top", 153 | "^b:Copy Sel", 154 | "Shift+↑↓:Sel", 155 | "^y:Copy All", 156 | "^t:Title", 157 | "^s:Select", 158 | "^e:External Editor", 159 | "^h:Help", 160 | "^l:Toggle Theme", 161 | ]; 162 | let commands = if is_edit_mode { 163 | &edit_commands 164 | } else { 165 | &normal_commands 166 | }; 167 | let thoth = "Thoth "; 168 | let separator = " | "; 169 | 170 | let thoth_width = thoth.width(); 171 | let separator_width = separator.width(); 172 | let reserved_width = thoth_width + BORDER_PADDING_SIZE; // 2 extra spaces for padding 173 | 174 | let mut display_commands = Vec::new(); 175 | let mut current_width = 0; 176 | 177 | for cmd in commands { 178 | let cmd_width = cmd.width(); 179 | if current_width + cmd_width + separator_width > available_width - reserved_width { 180 | break; 181 | } 182 | display_commands.push(*cmd); 183 | current_width += cmd_width + separator_width; 184 | } 185 | 186 | let command_string = display_commands.join(separator); 187 | let command_width = command_string.width(); 188 | 189 | let padding = " ".repeat(available_width - command_width - thoth_width - BORDER_PADDING_SIZE); 190 | 191 | let header = Line::from(vec![ 192 | Span::styled(command_string, Style::default().fg(theme.accent)), 193 | Span::styled(padding, Style::default().fg(theme.accent)), 194 | Span::styled(format!(" {} ", thoth), Style::default().fg(theme.accent)), 195 | ]); 196 | 197 | let tabs = Tabs::new(vec![header]) 198 | .style(Style::default().bg(theme.header_bg)) 199 | .divider(Span::styled("|", Style::default().fg(theme.accent))); 200 | 201 | f.render_widget(tabs, area); 202 | } 203 | 204 | pub fn render_help_popup(f: &mut Frame, popup: &UiPopup, theme: ThemeColors) { 205 | if !popup.visible { 206 | return; 207 | } 208 | 209 | let area = centered_rect(80, 80, f.size()); 210 | f.render_widget(ratatui::widgets::Clear, area); 211 | 212 | let border_color = if theme == *DARK_MODE_COLORS { 213 | theme.accent 214 | } else { 215 | theme.primary 216 | }; 217 | 218 | let text = Paragraph::new(popup.message.as_str()) 219 | .style(Style::default().fg(theme.foreground)) 220 | .block( 221 | Block::default() 222 | .borders(Borders::ALL) 223 | .border_style(Style::default().fg(border_color)) 224 | .title(format!("{} - Esc to exit", popup.popup_title)), 225 | ) 226 | .wrap(ratatui::widgets::Wrap { trim: true }); 227 | 228 | f.render_widget(text, area); 229 | } 230 | 231 | pub fn render_title_popup(f: &mut Frame, popup: &TitlePopup, theme: &ThemeColors) { 232 | let area = centered_rect(60, 20, f.size()); 233 | f.render_widget(ratatui::widgets::Clear, area); 234 | 235 | let text = Paragraph::new(popup.title.as_str()) 236 | .style(Style::default().bg(theme.background).fg(theme.foreground)) 237 | .block( 238 | Block::default() 239 | .borders(Borders::ALL) 240 | .border_style(Style::default().fg(theme.primary)) 241 | .title("Change Title"), 242 | ); 243 | f.render_widget(text, area); 244 | } 245 | 246 | pub fn render_code_block_popup(f: &mut Frame, popup: &CodeBlockPopup, theme: &ThemeColors) { 247 | if !popup.visible || popup.filtered_blocks.is_empty() { 248 | return; 249 | } 250 | 251 | let area = centered_rect(80, 80, f.size()); 252 | f.render_widget(ratatui::widgets::Clear, area); 253 | 254 | let chunks = Layout::default() 255 | .direction(Direction::Vertical) 256 | .constraints([Constraint::Length(3), Constraint::Min(1)]) 257 | .split(area); 258 | 259 | let title_area = chunks[0]; 260 | let code_area = chunks[1]; 261 | 262 | let title_block = Block::default() 263 | .borders(Borders::TOP | Borders::LEFT | Borders::RIGHT) 264 | .border_style(Style::default().fg(theme.primary)) 265 | .title(format!( 266 | "Code Block {}/{} [{}]", 267 | popup.selected_index + 1, 268 | popup.filtered_blocks.len(), 269 | if !popup.filtered_blocks.is_empty() { 270 | popup.filtered_blocks[popup.selected_index].language.clone() 271 | } else { 272 | String::new() 273 | } 274 | )); 275 | 276 | let title_text = vec![Line::from(vec![ 277 | Span::raw(" "), 278 | Span::styled("↑/↓", Style::default().fg(theme.accent)), 279 | Span::raw(": Navigate "), 280 | Span::styled("Enter", Style::default().fg(theme.accent)), 281 | Span::raw(": Copy "), 282 | Span::styled("Esc", Style::default().fg(theme.accent)), 283 | Span::raw(": Cancel"), 284 | ])]; 285 | 286 | let title_paragraph = Paragraph::new(title_text).block(title_block); 287 | 288 | f.render_widget(title_paragraph, title_area); 289 | 290 | if !popup.filtered_blocks.is_empty() { 291 | let selected_block = &popup.filtered_blocks[popup.selected_index]; 292 | 293 | let code_content = selected_block.content.clone(); 294 | let _language = &selected_block.language; 295 | 296 | let lines: Vec = code_content 297 | .lines() 298 | .enumerate() 299 | .map(|(i, line)| { 300 | Line::from(vec![ 301 | Span::styled( 302 | format!("{:3} │ ", i + 1), 303 | Style::default().fg(Color::DarkGray), 304 | ), 305 | Span::raw(line), 306 | ]) 307 | }) 308 | .collect(); 309 | 310 | let code_block = Block::default() 311 | .borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM) 312 | .border_style(Style::default().fg(theme.primary)); 313 | 314 | let code_paragraph = Paragraph::new(lines) 315 | .block(code_block) 316 | .wrap(Wrap { trim: false }); 317 | 318 | f.render_widget(code_paragraph, code_area); 319 | } 320 | } 321 | 322 | pub fn render_title_select_popup(f: &mut Frame, popup: &TitleSelectPopup, theme: &ThemeColors) { 323 | let area = centered_rect(80, 80, f.size()); 324 | f.render_widget(ratatui::widgets::Clear, area); 325 | 326 | let constraints = vec![Constraint::Min(1), Constraint::Length(3)]; 327 | 328 | let chunks = Layout::default() 329 | .direction(Direction::Vertical) 330 | .constraints(constraints) 331 | .split(area); 332 | 333 | let main_area = chunks[0]; 334 | let search_box = chunks[1]; 335 | 336 | let visible_height = main_area.height.saturating_sub(BORDER_PADDING_SIZE as u16) as usize; 337 | 338 | let start_idx = popup.scroll_offset; 339 | let end_idx = (popup.scroll_offset + visible_height).min(popup.filtered_titles.len()); 340 | let visible_titles = &popup.filtered_titles[start_idx..end_idx]; 341 | 342 | let items: Vec = visible_titles 343 | .iter() 344 | .enumerate() 345 | .map(|(i, title_match)| { 346 | let absolute_idx = i + popup.scroll_offset; 347 | if absolute_idx == popup.selected_index { 348 | Line::from(vec![Span::styled( 349 | format!("> {}", title_match.title), 350 | Style::default().fg(theme.accent), 351 | )]) 352 | } else { 353 | Line::from(vec![Span::raw(format!(" {}", title_match.title))]) 354 | } 355 | }) 356 | .collect(); 357 | 358 | let block = Block::default() 359 | .borders(Borders::ALL) 360 | .border_style(Style::default().fg(theme.primary)) 361 | .title("Select Title"); 362 | 363 | let paragraph = Paragraph::new(items) 364 | .block(block) 365 | .wrap(ratatui::widgets::Wrap { trim: true }); 366 | 367 | f.render_widget(paragraph, main_area); 368 | 369 | let search_block = Block::default() 370 | .borders(Borders::ALL) 371 | .border_style(Style::default().fg(theme.primary)) 372 | .title("Search"); 373 | 374 | let search_text = Paragraph::new(popup.search_query.as_str()).block(search_block); 375 | 376 | f.render_widget(search_text, search_box); 377 | } 378 | 379 | pub fn render_ui_popup(f: &mut Frame, popup: &UiPopup, theme: &ThemeColors) { 380 | if !popup.visible { 381 | return; 382 | } 383 | 384 | let area = centered_rect(popup.percent_x, popup.percent_y, f.size()); 385 | f.render_widget(ratatui::widgets::Clear, area); 386 | 387 | let text = Paragraph::new(popup.message.as_str()) 388 | .style(Style::default().fg(theme.error)) 389 | .block( 390 | Block::default() 391 | .borders(Borders::ALL) 392 | .border_style(Style::default().fg(theme.error)) 393 | .title(format!("{} - Esc to exit", popup.popup_title)), 394 | ) 395 | .wrap(ratatui::widgets::Wrap { trim: true }); 396 | 397 | f.render_widget(text, area); 398 | } 399 | 400 | pub fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { 401 | let popup_layout = Layout::default() 402 | .direction(Direction::Vertical) 403 | .constraints( 404 | [ 405 | Constraint::Percentage((100 - percent_y) / 2), 406 | Constraint::Percentage(percent_y), 407 | Constraint::Percentage((100 - percent_y) / 2), 408 | ] 409 | .as_ref(), 410 | ) 411 | .split(r); 412 | 413 | Layout::default() 414 | .direction(Direction::Horizontal) 415 | .constraints( 416 | [ 417 | Constraint::Percentage((100 - percent_x) / 2), 418 | Constraint::Percentage(percent_x), 419 | Constraint::Percentage((100 - percent_x) / 2), 420 | ] 421 | .as_ref(), 422 | ) 423 | .split(popup_layout[1])[1] 424 | } 425 | 426 | #[cfg(test)] 427 | mod tests { 428 | use ratatui::{backend::TestBackend, Terminal}; 429 | 430 | use crate::DARK_MODE_COLORS; 431 | use crate::ORANGE; 432 | 433 | use super::*; 434 | 435 | #[test] 436 | fn test_centered_rect() { 437 | let r = Rect::new(0, 0, 100, 100); 438 | let centered = centered_rect(50, 50, r); 439 | assert_eq!(centered.width, 50); 440 | assert_eq!(centered.height, 50); 441 | assert_eq!(centered.x, 25); 442 | assert_eq!(centered.y, 25); 443 | } 444 | 445 | #[test] 446 | fn test_render_header() { 447 | let backend = TestBackend::new(100, 1); 448 | let mut terminal = Terminal::new(backend).unwrap(); 449 | 450 | terminal 451 | .draw(|f| { 452 | let area = f.size(); 453 | render_header(f, area, false, &DARK_MODE_COLORS); 454 | }) 455 | .unwrap(); 456 | 457 | let buffer = terminal.backend().buffer(); 458 | 459 | assert!(buffer 460 | .content 461 | .iter() 462 | .any(|cell| cell.symbol().contains("Q"))); 463 | assert!(buffer 464 | .content 465 | .iter() 466 | .any(|cell| cell.symbol().contains("u"))); 467 | assert!(buffer 468 | .content 469 | .iter() 470 | .any(|cell| cell.symbol().contains("i"))); 471 | assert!(buffer 472 | .content 473 | .iter() 474 | .any(|cell| cell.symbol().contains("t"))); 475 | 476 | assert!(buffer.content.iter().any(|cell| cell.fg == ORANGE)); 477 | } 478 | 479 | #[test] 480 | fn test_render_title_popup() { 481 | let backend = TestBackend::new(100, 30); 482 | let mut terminal = Terminal::new(backend).unwrap(); 483 | let popup = TitlePopup { 484 | title: "Test Title".to_string(), 485 | visible: true, 486 | }; 487 | 488 | terminal 489 | .draw(|f| { 490 | render_title_popup(f, &popup, &DARK_MODE_COLORS); 491 | }) 492 | .unwrap(); 493 | 494 | let buffer = terminal.backend().buffer(); 495 | 496 | assert!(buffer 497 | .content 498 | .iter() 499 | .any(|cell| cell.symbol().contains("T"))); 500 | 501 | assert!(buffer 502 | .content 503 | .iter() 504 | .any(|cell| cell.symbol().contains("e"))); 505 | 506 | assert!(buffer 507 | .content 508 | .iter() 509 | .any(|cell| cell.symbol().contains("s"))); 510 | 511 | assert!(buffer 512 | .content 513 | .iter() 514 | .any(|cell| cell.symbol().contains("t"))); 515 | 516 | assert!(buffer 517 | .content 518 | .iter() 519 | .any(|cell| cell.symbol() == "─" || cell.symbol() == "│")); 520 | } 521 | 522 | #[test] 523 | fn test_render_title_select_popup() { 524 | let backend = TestBackend::new(100, 30); 525 | let mut terminal = Terminal::new(backend).unwrap(); 526 | let mut popup = TitleSelectPopup { 527 | titles: Vec::new(), 528 | selected_index: 0, 529 | visible: true, 530 | scroll_offset: 0, 531 | search_query: "".to_string(), 532 | filtered_titles: Vec::new(), 533 | }; 534 | 535 | popup.set_titles(vec!["Title1".to_string(), "Title2".to_string()]); 536 | 537 | terminal 538 | .draw(|f| { 539 | render_title_select_popup(f, &popup, &DARK_MODE_COLORS); 540 | }) 541 | .unwrap(); 542 | 543 | let buffer = terminal.backend().buffer(); 544 | 545 | assert!(buffer 546 | .content 547 | .iter() 548 | .any(|cell| cell.symbol().contains(">"))); 549 | assert!(buffer 550 | .content 551 | .iter() 552 | .any(|cell| cell.symbol().contains("2"))); 553 | 554 | assert!(buffer 555 | .content 556 | .iter() 557 | .any(|cell| cell.symbol().contains("1"))); 558 | } 559 | 560 | #[test] 561 | fn test_render_edit_commands_popup() { 562 | let backend = TestBackend::new(100, 30); 563 | let mut terminal = Terminal::new(backend).unwrap(); 564 | 565 | terminal 566 | .draw(|f| { 567 | render_edit_commands_popup(f, &DARK_MODE_COLORS); 568 | }) 569 | .unwrap(); 570 | 571 | let buffer = terminal.backend().buffer(); 572 | 573 | assert!(buffer 574 | .content 575 | .iter() 576 | .any(|cell| cell.symbol().contains("E"))); 577 | 578 | assert!(buffer 579 | .content 580 | .iter() 581 | .any(|cell| cell.symbol().contains("H"))); 582 | assert!(buffer 583 | .content 584 | .iter() 585 | .any(|cell| cell.symbol().contains("K"))); 586 | 587 | assert!(buffer 588 | .content 589 | .iter() 590 | .any(|cell| cell.symbol().contains("I") && cell.fg == ORANGE)); 591 | } 592 | } 593 | -------------------------------------------------------------------------------- /src/ui_handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | get_save_backup_file_path, utils::extract_code_blocks, CodeBlockPopup, EditorClipboard, 3 | ThemeMode, ThothConfig, 4 | }; 5 | use anyhow::{bail, Result}; 6 | use crossterm::{ 7 | event::{self, DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEventKind, KeyModifiers}, 8 | execute, 9 | terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, 10 | }; 11 | use ratatui::{backend::CrosstermBackend, Terminal}; 12 | use std::{ 13 | io::{self, Write}, 14 | time::Instant, 15 | }; 16 | use tui_textarea::TextArea; 17 | 18 | use crate::{ 19 | format_json, format_markdown, get_save_file_path, load_textareas, save_textareas, 20 | ui::{ 21 | render_code_block_popup, render_edit_commands_popup, render_header, render_title_popup, 22 | render_title_select_popup, render_ui_popup, EditCommandsPopup, UiPopup, 23 | }, 24 | ScrollableTextArea, TitlePopup, TitleSelectPopup, 25 | }; 26 | 27 | use std::env; 28 | use std::fs; 29 | use std::process::Command; 30 | use tempfile::NamedTempFile; 31 | 32 | pub struct UIState { 33 | pub scrollable_textarea: ScrollableTextArea, 34 | pub title_popup: TitlePopup, 35 | pub title_select_popup: TitleSelectPopup, 36 | pub error_popup: UiPopup, 37 | pub help_popup: UiPopup, 38 | pub copy_popup: UiPopup, 39 | pub edit_commands_popup: EditCommandsPopup, 40 | pub code_block_popup: CodeBlockPopup, 41 | pub clipboard: Option, 42 | pub last_draw: Instant, 43 | pub config: ThothConfig, 44 | } 45 | 46 | impl UIState { 47 | pub fn new() -> Result { 48 | let mut scrollable_textarea = ScrollableTextArea::new(); 49 | let main_save_path = get_save_file_path(); 50 | if main_save_path.exists() { 51 | let (loaded_textareas, loaded_titles) = load_textareas(main_save_path)?; 52 | for (textarea, title) in loaded_textareas.into_iter().zip(loaded_titles) { 53 | scrollable_textarea.add_textarea(textarea, title); 54 | } 55 | } else { 56 | scrollable_textarea.add_textarea(TextArea::default(), String::from("New Textarea")); 57 | } 58 | scrollable_textarea.initialize_scroll(); 59 | 60 | let config = ThothConfig::load()?; 61 | 62 | Ok(UIState { 63 | scrollable_textarea, 64 | title_popup: TitlePopup::new(), 65 | title_select_popup: TitleSelectPopup::new(), 66 | error_popup: UiPopup::new("Error".to_string(), 60, 20), 67 | copy_popup: UiPopup::new("Block Copied".to_string(), 60, 20), 68 | help_popup: UiPopup::new("Keyboard Shortcuts".to_string(), 60, 80), 69 | edit_commands_popup: EditCommandsPopup::new(), 70 | code_block_popup: CodeBlockPopup::new(), 71 | clipboard: EditorClipboard::try_new(), 72 | last_draw: Instant::now(), 73 | config, 74 | }) 75 | } 76 | } 77 | 78 | pub fn draw_ui( 79 | terminal: &mut Terminal>, 80 | state: &mut UIState, 81 | ) -> Result<()> { 82 | terminal.draw(|f| { 83 | let theme = state.config.get_theme_colors(); 84 | 85 | let chunks = ratatui::layout::Layout::default() 86 | .direction(ratatui::layout::Direction::Vertical) 87 | .constraints( 88 | [ 89 | ratatui::layout::Constraint::Length(1), 90 | ratatui::layout::Constraint::Min(1), 91 | ] 92 | .as_ref(), 93 | ) 94 | .split(f.size()); 95 | 96 | render_header(f, chunks[0], state.scrollable_textarea.edit_mode, theme); 97 | if state.scrollable_textarea.full_screen_mode { 98 | state 99 | .scrollable_textarea 100 | .render(f, f.size(), theme, &state.config.theme) 101 | .unwrap(); 102 | } else { 103 | state 104 | .scrollable_textarea 105 | .render(f, chunks[1], theme, &state.config.theme) 106 | .unwrap(); 107 | } 108 | 109 | if state.copy_popup.visible { 110 | render_ui_popup(f, &state.copy_popup, theme); 111 | } 112 | if state.title_popup.visible { 113 | render_title_popup(f, &state.title_popup, theme); 114 | } else if state.title_select_popup.visible { 115 | render_title_select_popup(f, &state.title_select_popup, theme); 116 | } else if state.code_block_popup.visible { 117 | render_code_block_popup(f, &state.code_block_popup, theme); 118 | } 119 | 120 | if state.edit_commands_popup.visible { 121 | render_edit_commands_popup(f, theme); 122 | } 123 | 124 | if state.error_popup.visible { 125 | render_ui_popup(f, &state.error_popup, theme); 126 | } 127 | if state.help_popup.visible { 128 | render_ui_popup(f, &state.help_popup, theme); 129 | } 130 | 131 | if state.help_popup.visible { 132 | render_ui_popup(f, &state.help_popup, theme); 133 | } 134 | })?; 135 | Ok(()) 136 | } 137 | 138 | fn handle_code_block_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result { 139 | let visible_items = 140 | (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 4; 141 | 142 | match key.code { 143 | KeyCode::Enter => { 144 | if !state.code_block_popup.filtered_blocks.is_empty() { 145 | let selected_index = state.code_block_popup.selected_index; 146 | let content = state.code_block_popup.filtered_blocks[selected_index] 147 | .content 148 | .clone(); 149 | let language = state.code_block_popup.filtered_blocks[selected_index] 150 | .language 151 | .clone(); 152 | 153 | if let Err(e) = copy_code_block_content_to_clipboard(state, &content, &language) { 154 | state.error_popup.show(format!("{}", e)); 155 | } 156 | 157 | state.code_block_popup.visible = false; 158 | } 159 | } 160 | KeyCode::Esc => { 161 | state.code_block_popup.visible = false; 162 | } 163 | KeyCode::Up => { 164 | state.code_block_popup.move_selection_up(visible_items); 165 | } 166 | KeyCode::Down => { 167 | state.code_block_popup.move_selection_down(visible_items); 168 | } 169 | _ => {} 170 | } 171 | Ok(false) 172 | } 173 | 174 | fn extract_and_show_code_blocks(state: &mut UIState) -> Result<()> { 175 | let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 176 | .lines() 177 | .join("\n"); 178 | 179 | let code_blocks = extract_code_blocks(&content); 180 | 181 | if code_blocks.is_empty() { 182 | state 183 | .error_popup 184 | .show("No code blocks found in the current note.".to_string()); 185 | return Ok(()); 186 | } 187 | 188 | state.code_block_popup.set_code_blocks(code_blocks); 189 | state.code_block_popup.visible = true; 190 | Ok(()) 191 | } 192 | 193 | fn copy_code_block_content_to_clipboard( 194 | state: &mut UIState, 195 | content: &str, 196 | language: &str, 197 | ) -> Result<()> { 198 | match &mut state.clipboard { 199 | Some(clip) => { 200 | if let Err(e) = clip.set_contents(content.to_string()) { 201 | let backup_path = crate::get_clipboard_backup_file_path(); 202 | std::fs::write(&backup_path, content)?; 203 | 204 | return Err(anyhow::anyhow!( 205 | "Clipboard error: {}.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 206 | e.to_string().split('\n').next().unwrap_or("Unknown error"), 207 | backup_path.display() 208 | )); 209 | } 210 | 211 | state.copy_popup.show(format!( 212 | "Copied code block [{}] to clipboard", 213 | if language.is_empty() { 214 | "no language" 215 | } else { 216 | language 217 | } 218 | )); 219 | } 220 | None => { 221 | let backup_path = crate::get_clipboard_backup_file_path(); 222 | std::fs::write(&backup_path, content)?; 223 | 224 | return Err(anyhow::anyhow!( 225 | "Clipboard unavailable.\nContent saved to: {}\nPlease use 'thoth read_clipboard' to read the contents from STDOUT.", 226 | backup_path.display() 227 | )); 228 | } 229 | } 230 | Ok(()) 231 | } 232 | 233 | pub fn handle_input( 234 | terminal: &mut Terminal>, 235 | state: &mut UIState, 236 | key: event::KeyEvent, 237 | ) -> Result { 238 | if key.kind != KeyEventKind::Press { 239 | return Ok(false); 240 | } 241 | 242 | if state.code_block_popup.visible { 243 | handle_code_block_popup_input(state, key) 244 | } else if state.scrollable_textarea.full_screen_mode { 245 | handle_full_screen_input(terminal, state, key) 246 | } else if state.title_popup.visible { 247 | handle_title_popup_input(state, key) 248 | } else if state.title_select_popup.visible { 249 | handle_title_select_popup_input(state, key) 250 | } else { 251 | handle_normal_input(terminal, state, key) 252 | } 253 | } 254 | 255 | fn handle_full_screen_input( 256 | terminal: &mut Terminal>, 257 | state: &mut UIState, 258 | key: event::KeyEvent, 259 | ) -> Result { 260 | match key.code { 261 | KeyCode::Esc => { 262 | if state.copy_popup.visible { 263 | state.copy_popup.hide(); 264 | } else if state.error_popup.visible { 265 | state.error_popup.hide(); 266 | } else if state.help_popup.visible { 267 | state.help_popup.hide(); 268 | } else if state.edit_commands_popup.visible { 269 | state.edit_commands_popup.visible = false; 270 | } else if state.scrollable_textarea.edit_mode { 271 | state.scrollable_textarea.edit_mode = false; 272 | } else { 273 | state.scrollable_textarea.toggle_full_screen(); 274 | state 275 | .scrollable_textarea 276 | .jump_to_textarea(state.scrollable_textarea.focused_index); 277 | } 278 | } 279 | KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { 280 | if state.scrollable_textarea.edit_mode { 281 | match edit_with_external_editor(state) { 282 | Ok(edited_content) => { 283 | let mut new_textarea = TextArea::default(); 284 | for line in edited_content.lines() { 285 | new_textarea.insert_str(line); 286 | new_textarea.insert_newline(); 287 | } 288 | state.scrollable_textarea.textareas 289 | [state.scrollable_textarea.focused_index] = new_textarea; 290 | 291 | terminal.clear()?; 292 | } 293 | Err(e) => { 294 | state 295 | .error_popup 296 | .show(format!("Failed to edit with external editor: {}", e)); 297 | } 298 | } 299 | } 300 | } 301 | KeyCode::Enter => { 302 | if !state.scrollable_textarea.edit_mode { 303 | state.scrollable_textarea.edit_mode = true; 304 | } else { 305 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 306 | .insert_newline(); 307 | } 308 | } 309 | KeyCode::Up => { 310 | if state.scrollable_textarea.edit_mode { 311 | handle_up_key(state, key); 312 | } else { 313 | state.scrollable_textarea.handle_scroll(-1); 314 | } 315 | } 316 | KeyCode::Down => { 317 | if state.scrollable_textarea.edit_mode { 318 | handle_down_key(state, key); 319 | } else { 320 | state.scrollable_textarea.handle_scroll(1); 321 | } 322 | } 323 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 324 | if !state.scrollable_textarea.edit_mode { 325 | if let Err(e) = extract_and_show_code_blocks(state) { 326 | state 327 | .error_popup 328 | .show(format!("Error extracting code blocks: {}", e)); 329 | } 330 | } else { 331 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 332 | .input(key); 333 | } 334 | } 335 | KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { 336 | match state.scrollable_textarea.copy_focused_textarea_contents() { 337 | Ok(_) => { 338 | let curr_focused_index = state.scrollable_textarea.focused_index; 339 | let curr_title_option = 340 | state.scrollable_textarea.titles.get(curr_focused_index); 341 | 342 | match curr_title_option { 343 | Some(curr_title) => { 344 | state 345 | .copy_popup 346 | .show(format!("Copied block {}", curr_title)); 347 | } 348 | None => { 349 | state 350 | .error_popup 351 | .show("Failed to copy selection with title".to_string()); 352 | } 353 | } 354 | } 355 | Err(e) => { 356 | state.error_popup.show(format!("{}", e)); 357 | } 358 | } 359 | } 360 | _ => { 361 | if state.scrollable_textarea.edit_mode { 362 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 363 | .input(key); 364 | } 365 | } 366 | } 367 | Ok(false) 368 | } 369 | 370 | fn handle_title_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result { 371 | match key.code { 372 | KeyCode::Enter => { 373 | #[allow(clippy::assigning_clones)] 374 | state 375 | .scrollable_textarea 376 | .change_title(state.title_popup.title.clone()); 377 | state.title_popup.visible = false; 378 | state.title_popup.title.clear(); 379 | } 380 | KeyCode::Esc => { 381 | state.title_popup.visible = false; 382 | state.title_popup.title.clear(); 383 | } 384 | KeyCode::Char(c) => { 385 | state.title_popup.title.push(c); 386 | } 387 | KeyCode::Backspace => { 388 | state.title_popup.title.pop(); 389 | } 390 | _ => {} 391 | } 392 | Ok(false) 393 | } 394 | 395 | fn handle_title_select_popup_input(state: &mut UIState, key: event::KeyEvent) -> Result { 396 | let visible_items = 397 | (state.scrollable_textarea.viewport_height as f32 * 0.8).floor() as usize - 10; 398 | 399 | match key.code { 400 | KeyCode::Enter => { 401 | if !state.title_select_popup.filtered_titles.is_empty() { 402 | let selected_title_match = &state.title_select_popup.filtered_titles 403 | [state.title_select_popup.selected_index]; 404 | state 405 | .scrollable_textarea 406 | .jump_to_textarea(selected_title_match.index); 407 | state.title_select_popup.visible = false; 408 | if !state.title_select_popup.search_query.is_empty() { 409 | state.title_select_popup.search_query.clear(); 410 | state.title_select_popup.reset_filtered_titles(); 411 | } 412 | } 413 | } 414 | KeyCode::Esc => { 415 | state.title_select_popup.visible = false; 416 | state.edit_commands_popup.visible = false; 417 | if !state.title_select_popup.search_query.is_empty() { 418 | state.title_select_popup.search_query.clear(); 419 | state.title_select_popup.reset_filtered_titles(); 420 | } 421 | } 422 | KeyCode::Up => { 423 | state.title_select_popup.move_selection_up(visible_items); 424 | } 425 | KeyCode::Down => { 426 | state.title_select_popup.move_selection_down(visible_items); 427 | } 428 | KeyCode::Char(c) => { 429 | state.title_select_popup.search_query.push(c); 430 | state.title_select_popup.update_search(); 431 | } 432 | KeyCode::Backspace => { 433 | state.title_select_popup.search_query.pop(); 434 | state.title_select_popup.update_search(); 435 | } 436 | 437 | _ => {} 438 | } 439 | Ok(false) 440 | } 441 | 442 | fn handle_normal_input( 443 | terminal: &mut Terminal>, 444 | state: &mut UIState, 445 | key: event::KeyEvent, 446 | ) -> Result { 447 | match key.code { 448 | KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => { 449 | format_current_textarea(state, format_markdown)?; 450 | } 451 | KeyCode::Char('j') if key.modifiers.contains(KeyModifiers::CONTROL) => { 452 | format_current_textarea(state, format_json)?; 453 | } 454 | KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => { 455 | if state.scrollable_textarea.edit_mode { 456 | match edit_with_external_editor(state) { 457 | Ok(edited_content) => { 458 | let mut new_textarea = TextArea::default(); 459 | for line in edited_content.lines() { 460 | new_textarea.insert_str(line); 461 | new_textarea.insert_newline(); 462 | } 463 | state.scrollable_textarea.textareas 464 | [state.scrollable_textarea.focused_index] = new_textarea; 465 | 466 | // Redraw the terminal after editing 467 | terminal.clear()?; 468 | } 469 | Err(e) => { 470 | state 471 | .error_popup 472 | .show(format!("Failed to edit with external editor: {}", e)); 473 | } 474 | } 475 | } 476 | } 477 | KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { 478 | let new_theme = match state.config.theme { 479 | ThemeMode::Light => ThemeMode::Dark, 480 | ThemeMode::Dark => ThemeMode::Light, 481 | }; 482 | state.config.set_theme(new_theme.clone())?; 483 | } 484 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { 485 | if !state.scrollable_textarea.edit_mode { 486 | if let Err(e) = extract_and_show_code_blocks(state) { 487 | state 488 | .error_popup 489 | .show(format!("Error extracting code blocks: {}", e)); 490 | } 491 | } else { 492 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 493 | .input(key); 494 | } 495 | } 496 | KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { 497 | match state.scrollable_textarea.copy_focused_textarea_contents() { 498 | Ok(_) => { 499 | let curr_focused_index = state.scrollable_textarea.focused_index; 500 | let curr_title_option = 501 | state.scrollable_textarea.titles.get(curr_focused_index); 502 | 503 | match curr_title_option { 504 | Some(curr_title) => { 505 | state 506 | .copy_popup 507 | .show(format!("Copied block {}", curr_title)); 508 | } 509 | None => { 510 | state 511 | .error_popup 512 | .show("Failed to copy selection with title".to_string()); 513 | } 514 | } 515 | } 516 | Err(e) => { 517 | state.error_popup.show(format!("{}", e)); 518 | } 519 | } 520 | } 521 | KeyCode::Char('b') if key.modifiers.contains(KeyModifiers::CONTROL) => { 522 | if let Err(e) = state.scrollable_textarea.copy_selection_contents() { 523 | state 524 | .error_popup 525 | .show(format!("Failed to copy to clipboard: {}", e)); 526 | } 527 | } 528 | KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { 529 | handle_paste(state)?; 530 | } 531 | KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { 532 | let help_message = "\ 533 | NAVIGATION: 534 | • ↑/↓ or j/k: Navigate between blocks 535 | • Enter: Enter edit mode 536 | • Esc: Exit current mode 537 | 538 | BLOCKS: 539 | • ^n: Add a new block 540 | • ^d: Delete current block 541 | • ^t: Change block title 542 | • ^s: Select block by title 543 | • ^f: Toggle fullscreen mode 544 | 545 | CLIPBOARD: 546 | • ^y: Copy current block 547 | • ^v: Paste from clipboard 548 | • ^b: Copy selection (in edit mode) 549 | • ^c: Copy code block from current note 550 | 551 | FORMATTING: 552 | • ^j: Format as JSON 553 | • ^k: Format as Markdown 554 | 555 | OTHER: 556 | • ^l: Toggle light/dark theme 557 | • ^e: Edit with external editor (in edit mode) 558 | • q: Quit application 559 | • ^h: Show this help"; 560 | 561 | state.help_popup.show(help_message.to_string()); 562 | } 563 | KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { 564 | if !state.scrollable_textarea.edit_mode { 565 | state.scrollable_textarea.toggle_full_screen(); 566 | } 567 | } 568 | KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => { 569 | if state.scrollable_textarea.edit_mode { 570 | state.edit_commands_popup.visible = !state.edit_commands_popup.visible; 571 | } 572 | } 573 | #[allow(clippy::assigning_clones)] 574 | KeyCode::Char('s') 575 | if key.modifiers.contains(KeyModifiers::CONTROL) 576 | && !key.modifiers.contains(KeyModifiers::SHIFT) => 577 | { 578 | // populate title_select_popup with the current titles from the textareas 579 | state 580 | .title_select_popup 581 | .set_titles(state.scrollable_textarea.titles.clone()); 582 | state.title_select_popup.selected_index = 0; 583 | state.title_select_popup.visible = true; 584 | } 585 | KeyCode::Char('q') => { 586 | if !state.scrollable_textarea.edit_mode { 587 | save_textareas( 588 | &state.scrollable_textarea.textareas, 589 | &state.scrollable_textarea.titles, 590 | get_save_file_path(), 591 | )?; 592 | save_textareas( 593 | &state.scrollable_textarea.textareas, 594 | &state.scrollable_textarea.titles, 595 | get_save_backup_file_path(), 596 | )?; 597 | return Ok(true); 598 | } 599 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index].input(key); 600 | } 601 | KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { 602 | if !state.scrollable_textarea.edit_mode { 603 | state 604 | .scrollable_textarea 605 | .add_textarea(TextArea::default(), String::from("New Textarea")); 606 | state.scrollable_textarea.adjust_scroll_to_focused(); 607 | } 608 | } 609 | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { 610 | if state.scrollable_textarea.textareas.len() > 1 && !state.scrollable_textarea.edit_mode 611 | { 612 | state 613 | .scrollable_textarea 614 | .remove_textarea(state.scrollable_textarea.focused_index); 615 | } 616 | } 617 | KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => { 618 | if state.scrollable_textarea.edit_mode { 619 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 620 | .move_cursor(tui_textarea::CursorMove::Top); 621 | } 622 | } 623 | #[allow(clippy::assigning_clones)] 624 | KeyCode::Char('t') if key.modifiers.contains(KeyModifiers::CONTROL) => { 625 | state.title_popup.visible = true; 626 | state.title_popup.title = 627 | state.scrollable_textarea.titles[state.scrollable_textarea.focused_index].clone(); 628 | } 629 | KeyCode::Enter => { 630 | if state.scrollable_textarea.edit_mode { 631 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 632 | .insert_newline(); 633 | } else { 634 | state.scrollable_textarea.edit_mode = true; 635 | } 636 | } 637 | KeyCode::Esc => { 638 | if state.edit_commands_popup.visible { 639 | state.edit_commands_popup.visible = false; 640 | } else { 641 | state.scrollable_textarea.edit_mode = false; 642 | state.edit_commands_popup.visible = false; 643 | } 644 | 645 | if state.error_popup.visible { 646 | state.error_popup.hide(); 647 | } 648 | if state.help_popup.visible { 649 | state.help_popup.hide(); 650 | } 651 | if state.copy_popup.visible { 652 | state.copy_popup.hide(); 653 | } 654 | } 655 | KeyCode::Up => handle_up_key(state, key), 656 | KeyCode::Down => handle_down_key(state, key), 657 | KeyCode::Char('k') if !state.scrollable_textarea.edit_mode => handle_up_key(state, key), 658 | KeyCode::Char('j') if !state.scrollable_textarea.edit_mode => handle_down_key(state, key), 659 | _ => { 660 | if state.scrollable_textarea.edit_mode { 661 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 662 | .input(key); 663 | state.scrollable_textarea.start_sel = usize::MAX; 664 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 665 | .cancel_selection(); 666 | } 667 | } 668 | } 669 | Ok(false) 670 | } 671 | 672 | fn handle_up_key(state: &mut UIState, key: event::KeyEvent) { 673 | if state.scrollable_textarea.edit_mode { 674 | let textarea = 675 | &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]; 676 | if key.modifiers.contains(KeyModifiers::SHIFT) { 677 | if state.scrollable_textarea.start_sel == usize::MAX { 678 | let (curr_row, _) = textarea.cursor(); 679 | state.scrollable_textarea.start_sel = curr_row; 680 | textarea.start_selection(); 681 | } 682 | if textarea.cursor().0 > 0 { 683 | textarea.move_cursor(tui_textarea::CursorMove::Up); 684 | } 685 | } else { 686 | textarea.move_cursor(tui_textarea::CursorMove::Up); 687 | state.scrollable_textarea.start_sel = usize::MAX; 688 | textarea.cancel_selection(); 689 | } 690 | } else { 691 | state.scrollable_textarea.move_focus(-1); 692 | } 693 | } 694 | 695 | fn handle_down_key(state: &mut UIState, key: event::KeyEvent) { 696 | if state.scrollable_textarea.edit_mode { 697 | let textarea = 698 | &mut state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index]; 699 | if key.modifiers.contains(KeyModifiers::SHIFT) { 700 | if state.scrollable_textarea.start_sel == usize::MAX { 701 | let (curr_row, _) = textarea.cursor(); 702 | state.scrollable_textarea.start_sel = curr_row; 703 | textarea.start_selection(); 704 | } 705 | if textarea.cursor().0 < textarea.lines().len() - 1 { 706 | textarea.move_cursor(tui_textarea::CursorMove::Down); 707 | } 708 | } else { 709 | textarea.move_cursor(tui_textarea::CursorMove::Down); 710 | state.scrollable_textarea.start_sel = usize::MAX; 711 | textarea.cancel_selection(); 712 | } 713 | } else { 714 | state.scrollable_textarea.move_focus(1); 715 | } 716 | } 717 | 718 | fn format_current_textarea(state: &mut UIState, formatter: F) -> Result<()> 719 | where 720 | F: Fn(&str) -> Result, 721 | { 722 | let current_content = state.scrollable_textarea.textareas 723 | [state.scrollable_textarea.focused_index] 724 | .lines() 725 | .join("\n"); 726 | match formatter(¤t_content) { 727 | Ok(formatted) => { 728 | let mut new_textarea = TextArea::default(); 729 | for line in formatted.lines() { 730 | new_textarea.insert_str(line); 731 | new_textarea.insert_newline(); 732 | } 733 | state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] = 734 | new_textarea; 735 | Ok(()) 736 | } 737 | Err(e) => { 738 | state 739 | .error_popup 740 | .show(format!("Failed to format block: {}", e)); 741 | Ok(()) 742 | } 743 | } 744 | } 745 | 746 | fn edit_with_external_editor(state: &mut UIState) -> Result { 747 | let content = state.scrollable_textarea.textareas[state.scrollable_textarea.focused_index] 748 | .lines() 749 | .join("\n"); 750 | let mut temp_file = NamedTempFile::new()?; 751 | 752 | temp_file.write_all(content.as_bytes())?; 753 | temp_file.flush()?; 754 | 755 | let editor = env::var("VISUAL") 756 | .or_else(|_| env::var("EDITOR")) 757 | .unwrap_or_else(|_| "vi".to_string()); 758 | 759 | // suspend the TUI 760 | disable_raw_mode()?; 761 | execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; 762 | 763 | let status = Command::new(&editor).arg(temp_file.path()).status()?; 764 | 765 | // resume the TUI 766 | enable_raw_mode()?; 767 | execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; 768 | 769 | if !status.success() { 770 | bail!(format!("Editor '{}' returned non-zero status", editor)); 771 | } 772 | 773 | let edited_content = fs::read_to_string(temp_file.path())?; 774 | 775 | Ok(edited_content) 776 | } 777 | 778 | fn handle_paste(state: &mut UIState) -> Result<()> { 779 | if state.scrollable_textarea.edit_mode { 780 | match &mut state.clipboard { 781 | Some(clip) => { 782 | if let Ok(content) = clip.get_content() { 783 | let textarea = &mut state.scrollable_textarea.textareas 784 | [state.scrollable_textarea.focused_index]; 785 | for line in content.lines() { 786 | textarea.insert_str(line); 787 | textarea.insert_newline(); 788 | } 789 | // Remove the last extra newline 790 | if content.ends_with('\n') { 791 | textarea.delete_char(); 792 | } 793 | } 794 | } 795 | None => { 796 | state 797 | .error_popup 798 | .show("Failed to create clipboard".to_string()); 799 | } 800 | } 801 | } 802 | Ok(()) 803 | } 804 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::code_block_popup::CodeBlock; 2 | use anyhow::Result; 3 | use std::io::{BufRead, Write}; 4 | use std::path::PathBuf; 5 | use std::{fs::File, io::BufReader}; 6 | use tui_textarea::TextArea; 7 | 8 | pub fn extract_code_blocks(content: &str) -> Vec { 9 | let mut code_blocks = Vec::new(); 10 | let mut in_code_block = false; 11 | let mut current_block = String::new(); 12 | let mut current_language = String::new(); 13 | let mut start_line = 0; 14 | 15 | for (i, line) in content.lines().enumerate() { 16 | if line.trim().starts_with("```") { 17 | if in_code_block { 18 | // End of code block 19 | code_blocks.push(CodeBlock::new( 20 | current_block.trim_end().to_string(), 21 | current_language.clone(), 22 | start_line, 23 | i, 24 | )); 25 | current_block.clear(); 26 | current_language.clear(); 27 | in_code_block = false; 28 | } else { 29 | // Start of code block 30 | in_code_block = true; 31 | start_line = i; 32 | 33 | let lang_part = line.trim_start_matches('`').trim(); 34 | current_language = lang_part.to_string(); 35 | } 36 | } else if in_code_block { 37 | current_block.push_str(line); 38 | current_block.push('\n'); 39 | } 40 | } 41 | 42 | if in_code_block && !current_block.is_empty() { 43 | code_blocks.push(CodeBlock::new( 44 | current_block.trim_end().to_string(), 45 | current_language, 46 | start_line, 47 | content.lines().count() - 1, 48 | )); 49 | } 50 | 51 | code_blocks 52 | } 53 | 54 | pub fn save_textareas(textareas: &[TextArea], titles: &[String], file_path: PathBuf) -> Result<()> { 55 | let mut file = File::create(file_path)?; 56 | for (textarea, title) in textareas.iter().zip(titles.iter()) { 57 | writeln!(file, "# {}", title)?; 58 | let content = textarea.lines().join("\n"); 59 | let mut in_code_block = false; 60 | for line in content.lines() { 61 | if line.trim().starts_with("```") { 62 | in_code_block = !in_code_block; 63 | } 64 | if in_code_block || !line.starts_with('#') { 65 | writeln!(file, "{}", line)?; 66 | } else { 67 | writeln!(file, "\\{}", line)?; 68 | } 69 | } 70 | } 71 | Ok(()) 72 | } 73 | 74 | pub fn load_textareas(file_path: PathBuf) -> Result<(Vec>, Vec)> { 75 | let file = File::open(file_path)?; 76 | let reader = BufReader::new(file); 77 | let mut textareas = Vec::with_capacity(10); 78 | let mut titles = Vec::with_capacity(10); 79 | let mut current_textarea = TextArea::default(); 80 | let mut current_title = String::new(); 81 | let mut in_code_block = false; 82 | let mut is_first_line = true; 83 | 84 | for line in reader.lines() { 85 | let line = line?; 86 | if !in_code_block && line.starts_with("# ") && is_first_line { 87 | current_title = line[2..].to_string(); 88 | is_first_line = false; 89 | } else { 90 | if line.trim().starts_with("```") { 91 | in_code_block = !in_code_block; 92 | } 93 | if in_code_block { 94 | current_textarea.insert_str(&line); 95 | } else if let Some(strip) = line.strip_prefix('\\') { 96 | current_textarea.insert_str(strip); 97 | } else if line.starts_with("# ") && !is_first_line { 98 | if !current_title.is_empty() { 99 | textareas.push(current_textarea); 100 | titles.push(current_title); 101 | } 102 | current_textarea = TextArea::default(); 103 | current_title = line[2..].to_string(); 104 | is_first_line = true; 105 | continue; 106 | } else { 107 | current_textarea.insert_str(&line); 108 | } 109 | current_textarea.insert_newline(); 110 | is_first_line = false; 111 | } 112 | } 113 | 114 | if !current_title.is_empty() { 115 | textareas.push(current_textarea); 116 | titles.push(current_title); 117 | } 118 | 119 | Ok((textareas, titles)) 120 | } 121 | 122 | #[cfg(test)] 123 | mod tests { 124 | use super::*; 125 | 126 | #[test] 127 | fn test_extract_code_blocks() { 128 | let content = r#"# Test Document 129 | 130 | ```rust 131 | fn main() { 132 | println!("Hello, world!"); 133 | } 134 | ``` 135 | 136 | Some text here 137 | 138 | ```python 139 | def hello(): 140 | print("Hello") 141 | ```"#; 142 | 143 | let blocks = extract_code_blocks(content); 144 | assert_eq!(blocks.len(), 2); 145 | assert_eq!(blocks[0].language, "rust"); 146 | assert_eq!( 147 | blocks[0].content, 148 | "fn main() {\n println!(\"Hello, world!\");\n}" 149 | ); 150 | assert_eq!(blocks[1].language, "python"); 151 | assert_eq!(blocks[1].content, "def hello():\n print(\"Hello\")"); 152 | } 153 | 154 | #[test] 155 | fn test_extract_empty_code_blocks() { 156 | let content = "```rust\n```"; 157 | let blocks = extract_code_blocks(content); 158 | assert_eq!(blocks.len(), 1); 159 | assert_eq!(blocks[0].language, "rust"); 160 | assert_eq!(blocks[0].content, ""); 161 | } 162 | 163 | #[test] 164 | fn test_unclosed_code_block() { 165 | let content = "```js\nlet x = 1;"; 166 | let blocks = extract_code_blocks(content); 167 | assert_eq!(blocks.len(), 1); 168 | assert_eq!(blocks[0].language, "js"); 169 | assert_eq!(blocks[0].content, "let x = 1;"); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /tests/integration_tests.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::cell::RefCell; 3 | use std::sync::{Arc, Mutex}; 4 | use thoth_cli::{ 5 | format_json, format_markdown, get_save_file_path, EditorClipboard, ScrollableTextArea, 6 | TitlePopup, TitleSelectPopup, 7 | }; 8 | use tui_textarea::TextArea; 9 | 10 | #[cfg(test)] 11 | mod test_utils { 12 | use super::*; 13 | use std::sync::{Arc, Mutex}; 14 | 15 | pub struct MockClipboard { 16 | content: String, 17 | } 18 | 19 | impl MockClipboard { 20 | pub fn new() -> Self { 21 | MockClipboard { 22 | content: String::new(), 23 | } 24 | } 25 | 26 | pub fn set_content(&mut self, content: String) -> Result<()> { 27 | self.content = content; 28 | Ok(()) 29 | } 30 | 31 | pub fn get_content(&self) -> Result { 32 | Ok(self.content.clone()) 33 | } 34 | } 35 | } 36 | 37 | #[test] 38 | fn test_full_application_flow() { 39 | // Initialize ScrollableTextArea 40 | let mut sta = ScrollableTextArea::new(); 41 | 42 | // Add textareas 43 | sta.add_textarea(TextArea::default(), "Note 1".to_string()); 44 | sta.add_textarea(TextArea::default(), "Note 2".to_string()); 45 | assert_eq!(sta.textareas.len(), 2); 46 | assert_eq!(sta.titles.len(), 2); 47 | 48 | // Edit content 49 | sta.textareas[0].insert_str("This is the content of Note 1"); 50 | sta.textareas[1].insert_str("This is the content of Note 2"); 51 | 52 | // Test focus movement 53 | sta.move_focus(1); 54 | assert_eq!(sta.focused_index, 0); 55 | sta.move_focus(-1); 56 | assert_eq!(sta.focused_index, 1); 57 | sta.move_focus(1); 58 | assert_eq!(sta.focused_index, 0); 59 | 60 | // Test title change 61 | sta.change_title("Updated Note 1".to_string()); 62 | assert_eq!(sta.titles[0], "Updated Note 1"); 63 | 64 | // Test clipboard content extraction 65 | let expected_content = sta.test_get_clipboard_content(); 66 | assert_eq!(expected_content, "This is the content of Note 1"); 67 | 68 | // Create and test with mock clipboard 69 | let mut mock_clipboard = test_utils::MockClipboard::new(); 70 | let _ = mock_clipboard.set_content(expected_content.clone()); 71 | let clipboard_content = mock_clipboard.get_content().unwrap(); 72 | assert_eq!(clipboard_content, "This is the content of Note 1"); 73 | 74 | // Test remove textarea 75 | sta.remove_textarea(1); 76 | assert_eq!(sta.textareas.len(), 1); 77 | assert_eq!(sta.titles.len(), 1); 78 | 79 | // Test full screen toggle 80 | sta.toggle_full_screen(); 81 | assert!(sta.full_screen_mode); 82 | assert!(!sta.edit_mode); 83 | 84 | // Test markdown formatting 85 | let markdown_content = "# Header\n\nThis is **bold** and *italic* text."; 86 | let formatted_markdown = format_markdown(markdown_content).unwrap(); 87 | assert!(formatted_markdown.contains("# Header")); 88 | assert!(formatted_markdown.contains("**bold**")); 89 | assert!(formatted_markdown.contains("*italic*")); 90 | 91 | // Test JSON formatting 92 | let json_content = r#"{"name":"John","age":30}"#; 93 | let formatted_json = format_json(json_content).unwrap(); 94 | assert!(formatted_json.contains("\"name\": \"John\"")); 95 | assert!(formatted_json.contains("\"age\": 30")); 96 | 97 | // Test TitlePopup 98 | let mut title_popup = TitlePopup::new(); 99 | title_popup.title = "New Title".to_string(); 100 | title_popup.visible = true; 101 | assert_eq!(title_popup.title, "New Title"); 102 | assert!(title_popup.visible); 103 | 104 | // Test TitleSelectPopup 105 | let mut title_select_popup = TitleSelectPopup::new(); 106 | title_select_popup.titles = vec!["Title1".to_string(), "Title2".to_string()]; 107 | title_select_popup.selected_index = 1; 108 | title_select_popup.visible = true; 109 | assert_eq!(title_select_popup.titles.len(), 2); 110 | assert_eq!(title_select_popup.selected_index, 1); 111 | assert!(title_select_popup.visible); 112 | 113 | // Test save file path 114 | let save_path = get_save_file_path(); 115 | assert!(save_path.ends_with("thoth_notes.md")); 116 | } 117 | 118 | #[test] 119 | fn test_clipboard_functionality() { 120 | // Create a mock clipboard 121 | let mut mock_clipboard = test_utils::MockClipboard::new(); 122 | 123 | // Initialize ScrollableTextArea 124 | let mut sta = ScrollableTextArea::new(); 125 | 126 | // Create a textarea with content 127 | let mut textarea = TextArea::default(); 128 | textarea.insert_str("Test clipboard content"); 129 | sta.add_textarea(textarea, "Clipboard Test".to_string()); 130 | 131 | // Get the content that would be copied 132 | let content = sta.textareas[sta.focused_index].lines().join("\n"); 133 | 134 | // Store it in our mock clipboard 135 | mock_clipboard.set_content(content).unwrap(); 136 | 137 | // Retrieve from mock clipboard 138 | let clipboard_content = mock_clipboard.get_content().unwrap(); 139 | 140 | // Verify content 141 | assert_eq!(clipboard_content, "Test clipboard content"); 142 | 143 | // Test copy selection by mocking line selection 144 | sta.start_sel = 0; 145 | let current_line = sta.textareas[sta.focused_index].lines()[0].clone(); 146 | mock_clipboard.set_content(current_line.clone()).unwrap(); 147 | 148 | // Verify selection content 149 | assert_eq!( 150 | mock_clipboard.get_content().unwrap(), 151 | "Test clipboard content" 152 | ); 153 | } 154 | --------------------------------------------------------------------------------