From 36eff02b18c712d50259fdbf0236842a6a13a74c Mon Sep 17 00:00:00 2001 From: oop7 <110548351+oop7@users.noreply.github.com> Date: Sun, 1 Feb 2026 12:36:00 +0200 Subject: [PATCH] Add widget shake animation for invalid input Introduce a shake animation to visually indicate invalid inputs and wire it into validation/error flows. Changes: import QPoint; add animate_widget_shake(QWidget) implementation that uses QPropertyAnimation on widget.pos with keyframes; call the animation when URL/path/format validations fail and replace some direct status_label updates with set_status_message_animated. Updated files: ytsage/gui/ytsage_gui_main.py and ytsage/gui/ytsage_gui_analysis.py. --- ytsage/gui/ytsage_gui_analysis.py | 4 +++ ytsage/gui/ytsage_gui_main.py | 46 +++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/ytsage/gui/ytsage_gui_analysis.py b/ytsage/gui/ytsage_gui_analysis.py index 0a37739..46604ce 100644 --- a/ytsage/gui/ytsage_gui_analysis.py +++ b/ytsage/gui/ytsage_gui_analysis.py @@ -271,12 +271,16 @@ class AnalysisMixin: url = self.url_input.text().strip() if not url: self.signals.update_status.emit(_("main_ui.invalid_url_or_enter")) + if hasattr(self, "animate_widget_shake"): + self.animate_widget_shake(self.url_input) return # Validate URL before processing is_valid, error_message = validate_video_url(url) if not is_valid: QMessageBox.warning(self, _("main_ui.error_title"), error_message) + if hasattr(self, "animate_widget_shake"): + self.animate_widget_shake(self.url_input) return # Cancel any existing analysis thread diff --git a/ytsage/gui/ytsage_gui_main.py b/ytsage/gui/ytsage_gui_main.py index 43748ca..b48cd6f 100644 --- a/ytsage/gui/ytsage_gui_main.py +++ b/ytsage/gui/ytsage_gui_main.py @@ -7,7 +7,7 @@ from pathlib import Path import markdown import requests from packaging import version -from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve +from PySide6.QtCore import Q_ARG, QMetaObject, Qt, QTimer, Slot, QThread, Signal, QUrl, QPropertyAnimation, QEasingCurve, QPoint from PySide6.QtGui import QIcon from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer from PySide6.QtWidgets import ( @@ -598,11 +598,15 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): if not url or not path: # More specific error message if path is missing if not path: - self.status_label.setText(_('download.please_set_path')) + self.set_status_message_animated(_('download.please_set_path')) + self.animate_widget_shake(self.settings_button) elif not url: - self.status_label.setText(_('download.please_enter_url')) + self.set_status_message_animated(_('download.please_enter_url')) + self.animate_widget_shake(self.url_input) else: - self.status_label.setText(_('download.please_enter_url_and_path')) + self.set_status_message_animated(_('download.please_enter_url_and_path')) + self.animate_widget_shake(self.url_input) + self.animate_widget_shake(self.settings_button) return # --- End Path Change --- @@ -610,12 +614,14 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): is_valid, error_message = validate_video_url(url) if not is_valid: QMessageBox.warning(self, _("main_ui.error_title"), error_message) + self.animate_widget_shake(self.url_input) return # Get selected format selected_format = self.get_selected_format() if not selected_format: - self.status_label.setText(_('download.please_select_format')) + self.set_status_message_animated(_('download.please_select_format')) + self.animate_widget_shake(self.format_table) return format_id = selected_format["format_id"] is_audio_only = bool(selected_format.get("is_audio_only")) @@ -1539,6 +1545,36 @@ class YTSageApp(QMainWindow, FormatTableMixin, VideoInfoMixin, AnalysisMixin): else: self.animate_widget_fade_out(widget) + def animate_widget_shake(self, widget: QWidget) -> None: + """Shake a widget left and right to indicate an error or invalid input.""" + # Stop shake if running + if hasattr(widget, '_shake_anim'): + try: + if widget._shake_anim.state() == QPropertyAnimation.State.Running: + return + except RuntimeError: + pass + + # Use current position as baseline + pos = widget.pos() + x = pos.x() + y = pos.y() + + anim = QPropertyAnimation(widget, b"pos", widget) + anim.setDuration(300) + anim.setLoopCount(1) + + # Create keyframes for shake effect + anim.setKeyValueAt(0, QPoint(x, y)) + anim.setKeyValueAt(0.2, QPoint(x - 5, y)) + anim.setKeyValueAt(0.4, QPoint(x + 5, y)) + anim.setKeyValueAt(0.6, QPoint(x - 5, y)) + anim.setKeyValueAt(0.8, QPoint(x + 5, y)) + anim.setKeyValueAt(1.0, QPoint(x, y)) + + widget._shake_anim = anim + anim.start(QPropertyAnimation.DeletionPolicy.DeleteWhenStopped) + def set_status_message_animated(self, message: str) -> None: """Update status label with a cross-fade animation."""