Skip to content

Feat/spinner pulse 1739#1944

Merged
Karanjot786 merged 5 commits into
Karanjot786:mainfrom
Shan7Usmani:feat/spinner-pulse-1739
Jul 3, 2026
Merged

Feat/spinner pulse 1739#1944
Karanjot786 merged 5 commits into
Karanjot786:mainfrom
Shan7Usmani:feat/spinner-pulse-1739

Conversation

@Shan7Usmani

@Shan7Usmani Shan7Usmani commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Description

Adds an optional animation: 'pulse' mode to Spinner that smoothly oscillates a single character between dim and bold using fadeIn/fadeOut from @termuijs/motion. Existing 'spin' default (frame cycling) is unchanged.

Related Issue

Closes #1739

Which package(s)?

@termuijs/widgets

Type of Change

  • 🐛 Bug fix (type:bug)
  • ✨ Feature (type:feature)
  • 📝 Docs (type:docs)
  • 🧪 Tests (type:testing)
  • ♻️ Refactor (type:refactor)
  • 🎨 Design / UX (type:design)
  • ♿ Accessibility (type:accessibility)
  • ⚡ Performance (type:performance)
  • 🔧 DevOps / CI (type:devops)
  • 🔒 Security (type:security)

Checklist

  • ⭐ You starred the repo. The needs-star check blocks your merge otherwise.
  • Tests pass locally: bun vitest run
  • Build passes: bun run build
  • Typecheck passes: bun run typecheck
  • You read CONTRIBUTING.md.
  • Your PR title follows type: short description.
  • Widget state mutators call markDirty() (if your change affects rendering).
  • No new any types without an inline comment explaining why.
  • No unrelated refactors bundled into this PR.

GSSoC 2026 Participation

  • You are a GSSoC 2026 contributor.
  • Your GSSoC profile: https://gssoc.girlscript.org/profile/ac85deec-3598-47ef-a9e8-f39ae0af5147

Screenshots / Recordings (UI changes)

image

Notes for the Reviewer

  • "Rotate" vs "pulse": The existing 'spin' mode (default, unchanged) cycles through frame characters (⠋⠙⠹...) — this is the terminal equivalent of rotation and was already present pre-PR. This PR adds 'pulse' as an additional animation style, not a replacement.
  • Size variants: The Spinner has no small/medium/large concept (it renders a single character at height: 1). The requirement from the issue doesn't map onto the actual API — no changes needed.
  • Reduced motion: Pulse animation becomes a static character when prefersReducedMotion() is true, consistent with other animated widgets.
  • 21 tests pass (15 existing + 6 new), build 42/42, typecheck 30/30.

Animate Checkbox checkmark with fadeIn/fadeOut using terminal dim attribute.
Animate Switch knob sliding through intermediate positions (●── → ─●─ → ──●).
Respect prefersReducedMotion() — skip animation when caps.motion is false.
Add @termuijs/motion dependency to @termuijs/ui package.

Closes Karanjot786#1738
@Shan7Usmani Shan7Usmani requested a review from Karanjot786 as a code owner July 2, 2026 19:38
@github-actions github-actions Bot added area:widgets @termuijs/widgets area:examples Example apps. area:ui @termuijs/ui type:testing +10 pts. Tests. labels Jul 2, 2026
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds motion-based animations to Switch, Checkbox, Tooltip, and Spinner widgets using a new @termuijs/motion dependency. Each widget gains progress/opacity state driven by fadeIn/fadeOut, reduced-motion fallbacks, and updated mount/unmount/render logic. A demo script and expanded tests are included.

Changes

Widget Animation Cohort

Layer / File(s) Summary
Motion dependency setup
packages/ui/package.json
Adds @termuijs/motion as a workspace dependency.
Switch knob animation
packages/ui/src/Switch.ts
Adds _animProgress/_animCancel state, animates knob transitions in setValue, updates mount/unmount, and renders track/knob cells based on progress.
Checkbox checkmark animation
packages/widgets/src/input/Checkbox.ts
Reworks setChecked/toggle to animate checkmark via fadeIn/fadeOut, adds mount/unmount lifecycle handling, and updates mark rendering/styling based on progress.
Tooltip fade visibility animation
packages/widgets/src/display/Tooltip.ts, packages/widgets/src/display/Tooltip.test.ts
Rewrites setVisible to animate opacity, adds mount/unmount hooks, updates rendering with computed cell attributes, and expands tests for visibility, reduced-motion, and lifecycle behavior.
Spinner pulse animation mode
packages/widgets/src/feedback/Spinner.ts, packages/widgets/src/feedback/Spinner.test.ts
Adds animation/pulseChar options, pulse fade loop via setActive/tick/mount/unmount, updated rendering with dim/bold styling, and pulse animation tests.
Animation demo script
examples/demo-animations.ts
Adds a standalone demo constructing checkboxes/switches, mounting an App, and wiring keyboard-driven toggling and quit handling.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Widget as Widget (Switch/Checkbox/Tooltip/Spinner)
  participant Motion as `@termuijs/motion`

  User->>Widget: state change (setValue/setChecked/setVisible/setActive)
  Widget->>Widget: cancel in-flight animation
  alt prefersReducedMotion
    Widget->>Widget: snap progress/opacity to end state
  else animate
    Widget->>Motion: fadeIn/fadeOut(150ms)
    Motion-->>Widget: progress/opacity updates
    Widget->>Widget: markDirty()
  end
  Widget->>Widget: _renderSelf uses animation state
Loading

Possibly related PRs

  • Karanjot786/TermUI#955: Both PRs modify Spinner.ts to gate animation/rendering based on prefersReducedMotion, directly overlapping the animation logic added here.
  • Karanjot786/TermUI#1581: Both PRs modify SpinnerOptions and constructor/preset/animation logic in Spinner.ts, overlapping at the Spinner API layer.
  • Karanjot786/TermUI#1582: Both PRs modify Checkbox.ts implementation, with this PR adding animated checkmark transitions and lifecycle handling.

Suggested labels: type:feature, type:accessibility

Suggested reviewers: Karanjot786

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes unrelated animation updates in Checkbox, Tooltip, Switch, and a demo script beyond the Spinner scope of #1739. Move the non-Spinner animation and demo changes to a separate PR, and keep this one focused on Spinner pulse behavior and tests.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The Spinner changes add the requested pulse mode and reduced-motion handling for #1739, covering the core loading-indicator goal.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title is concise and directly describes the spinner pulse feature added by this PR.
Description check ✅ Passed The description follows the template and fills the required sections with relevant details, issue link, package, checklist, screenshots, and notes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (2)
packages/widgets/src/display/Tooltip.test.ts (1)

113-127: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test doesn't actually exercise "cancels previous animation".

The title claims cancellation of an in-progress fade-in, but the test never starts a fade-in before calling setVisible(false); it only asserts fadeOut was called once, which would pass even without any cancellation logic. Consider driving an actual in-flight fadeIn first (e.g. mock fadeIn to capture its cancel function) and asserting that cancel function is invoked when setVisible(false) runs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widgets/src/display/Tooltip.test.ts` around lines 113 - 127, The
Tooltip test currently only checks that Tooltip.setVisible(false) calls
motion.fadeOut, so it never proves an in-progress fade-in is canceled. Update
the test in Tooltip.test.ts to first trigger a real fade-in path by constructing
Tooltip with visible false and then calling setVisible(true) while mocking
motion.fadeIn to return a cancel function, then call setVisible(false) and
assert that the captured cancel function was invoked before fadeOut. Use the
existing Tooltip, setVisible, motion.fadeIn, and motion.fadeOut symbols to keep
the test focused on the cancellation behavior.
packages/widgets/src/feedback/Spinner.ts (1)

243-255: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

mount() doesn't clean up existing timers/animations before starting new ones.

setActive() cancels _timerUnsub/_animCancel before starting a new one (lines 176-181), but mount() doesn't apply the same guard. If mount() were ever called again without an intervening unmount() (e.g. re-parenting), this would leak a duplicate setInterval subscription or a duplicate pulse fade loop running in parallel. Mirroring setActive()'s cleanup here would make the lifecycle robust regardless of caller assumptions, and would also remove the visible jump from the constructor's initial _animProgress = 0.5 straight to 0 when _startPulse() kicks in.

🔧 Proposed fix
     mount(): void {
         super.mount();
+        this._timerUnsub?.();
+        this._animCancel?.();
         if (prefersReducedMotion() || !this._active) return;
         if (this._animation === 'pulse') {
             this._startPulse();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widgets/src/feedback/Spinner.ts` around lines 243 - 255, The
Spinner.mount() lifecycle method starts a new timer or pulse animation without
clearing any existing _timerUnsub/_animCancel state first, unlike setActive();
update mount() to mirror the same cleanup guard before calling _startPulse() or
timerPoolSubscribe so repeated mounts cannot leak duplicate animation loops or
intervals. Use the Spinner class methods mount(), setActive(), _startPulse(),
and the _timerUnsub/_animCancel fields to locate and apply the fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/demo-animations.ts`:
- Around line 38-52: The global key handler in the demo is toggling all widgets
on space/enter, which can conflict with focused widgets’ own key handling and
cause double-toggles. Update the key listener in the demo’s event handler to
avoid manually calling toggle() on cb1/cb2/cb3/sw1/sw2/sw3 for those keys, and
rely on each widget’s built-in handleKey behavior or add explicit focus
navigation if you want to demonstrate interaction. Use the app.events.on('key',
...) handler and the widget instances in this demo to locate the change.

In `@packages/ui/src/Switch.ts`:
- Around line 80-86: The Switch mount/unmount lifecycle leaves _animProgress
stale for the off state, so a remounted switch can stay mid-transition after a
cancelled fadeOut. Update Switch.mount() (and, if needed, the unmount/reset
path) to resync _animProgress for both this._value true and false so the visual
state always matches the current value after remounting, then markDirty() when
the value-based progress is corrected.

In `@packages/widgets/src/display/Tooltip.test.ts`:
- Line 99: The Tooltip tests are mocking the plain boolean caps.motion
incorrectly with a getter spy, which will not work reliably. Update the
reduced-motion test setup in Tooltip.test.ts to override caps.motion via
Object.defineProperty with a configurable value, and make sure the original
value is restored after each test. Use the caps.motion reference in the affected
describe/test block to keep the mock localized and repeatable.

In `@packages/widgets/src/display/Tooltip.ts`:
- Around line 42-48: The Tooltip remount path is leaving a stale `_animOpacity`
behind after an interrupted fade-out, so a hidden tooltip can still render on
remount. Update `Tooltip.mount()` to also normalize the hidden case by resetting
`_animOpacity` when `_visible` is false (or otherwise ensuring hidden state
cannot keep a positive opacity), alongside the existing visible-state
correction. Make sure the fix works with `setVisible(false)`, `unmount()`, and
`_renderSelf()` so remounting a hidden tooltip cannot show border/content until
visibility changes again.

In `@packages/widgets/src/feedback/Spinner.test.ts`:
- Around line 153-160: The Spinner test setup is using getter spies on
caps.motion and caps.unicode, but those env capability fields are plain values,
not accessors. Update the beforeEach in Spinner.test.ts to override caps.motion
and caps.unicode directly, and make sure the original values are restored
explicitly in afterEach; keep the change localized to the Spinner — Pulse
animation block and reference the caps object usage there.

In `@packages/widgets/src/input/Checkbox.ts`:
- Around line 145-151: The Checkbox mount() logic only restores animation
progress for the checked state, so interrupted fadeOut transitions can leave
_animProgress mid-transition on remount. Update Checkbox.mount() to mirror
Switch.mount() by correcting both branches: when _checked is true clamp
_animProgress to 1, and when _checked is false restore it to 0 if it was left
partially transitioned, then call markDirty() so the visual state is reset.

---

Nitpick comments:
In `@packages/widgets/src/display/Tooltip.test.ts`:
- Around line 113-127: The Tooltip test currently only checks that
Tooltip.setVisible(false) calls motion.fadeOut, so it never proves an
in-progress fade-in is canceled. Update the test in Tooltip.test.ts to first
trigger a real fade-in path by constructing Tooltip with visible false and then
calling setVisible(true) while mocking motion.fadeIn to return a cancel
function, then call setVisible(false) and assert that the captured cancel
function was invoked before fadeOut. Use the existing Tooltip, setVisible,
motion.fadeIn, and motion.fadeOut symbols to keep the test focused on the
cancellation behavior.

In `@packages/widgets/src/feedback/Spinner.ts`:
- Around line 243-255: The Spinner.mount() lifecycle method starts a new timer
or pulse animation without clearing any existing _timerUnsub/_animCancel state
first, unlike setActive(); update mount() to mirror the same cleanup guard
before calling _startPulse() or timerPoolSubscribe so repeated mounts cannot
leak duplicate animation loops or intervals. Use the Spinner class methods
mount(), setActive(), _startPulse(), and the _timerUnsub/_animCancel fields to
locate and apply the fix.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f3459d0e-21fb-466c-b368-9577e0dffbf6

📥 Commits

Reviewing files that changed from the base of the PR and between 85e8d36 and 0106346.

📒 Files selected for processing (8)
  • examples/demo-animations.ts
  • packages/ui/package.json
  • packages/ui/src/Switch.ts
  • packages/widgets/src/display/Tooltip.test.ts
  • packages/widgets/src/display/Tooltip.ts
  • packages/widgets/src/feedback/Spinner.test.ts
  • packages/widgets/src/feedback/Spinner.ts
  • packages/widgets/src/input/Checkbox.ts

Comment thread examples/demo-animations.ts
Comment thread packages/ui/src/Switch.ts
Comment thread packages/widgets/src/display/Tooltip.test.ts
Comment thread packages/widgets/src/display/Tooltip.ts
Comment thread packages/widgets/src/feedback/Spinner.test.ts
Comment thread packages/widgets/src/input/Checkbox.ts
@Karanjot786 Karanjot786 added gssoc:approved Approved PR. Earns +50 base points. quality:clean x 1.2 multiplier. Clean implementation. level:intermediate +35 pts. Moderate task. labels Jul 3, 2026
@Karanjot786 Karanjot786 merged commit f4a27cd into Karanjot786:main Jul 3, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:examples Example apps. area:ui @termuijs/ui area:widgets @termuijs/widgets gssoc:approved Approved PR. Earns +50 base points. level:intermediate +35 pts. Moderate task. quality:clean x 1.2 multiplier. Clean implementation. type:testing +10 pts. Tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature] Add smooth rotate/pulse animation for Loading Spinner component

2 participants