from PySide6.QtWidgets import QLabel, QMenu, QVBoxLayout, QWidget from PySide6.QtCore import Qt,Signal, QPoint from PySide6.QtGui import QMouseEvent, QEnterEvent, QDragLeaveEvent, QCursor import logging logger = logging.getLogger(__name__) class ClickableLabel(QLabel): """ 클릭 / 더블클릭 / 우클릭 컨텍스트 메뉴 / 마우스 호버링 / 드래그 / 그리드 스냅 지원 QLabel [Signals] - clicked(): 좌클릭 - double_clicked(): 좌측 더블클릭 - right_clicked(pos: QPoint): 우클릭 (로컬 좌표) - hover_entered(): 마우스 진입 - hover_left(): 마우스 이탈 - drag_started(pos: QPoint): 드래그 시작 - dragging(delta: QPoint): 드래그 중 (이동량) - drag_finished(): 드래그 종료 /사용예시 label = ClickableLabel("클릭해보세요") label.clicked.connect(lambda: logger.info("원클릭")) label.double_clicked.connect(lambda: logger.info("더블클릭")) label.hover_entered.connect(lambda: logger.info("마우스 진입")) label.hover_left.connect(lambda: logger.info("마우스 이탈")) /우클릭 컨텍스트 label.right_clicked.connect( lambda pos: logger.info(f"우클릭 위치: {pos}") ) /드래그 label.drag_started.connect( lambda pos: logger.info(f"드래그 시작 위치: {pos}") ) label.dragging.connect( lambda delta: logger.info(f"드래그 이동량: {delta}") ) label.drag_finished.connect(lambda: logger.info("드래그 종료")) def on_drag(delta: QPoint): label.move(label.pos() + delta) label.dragging.connect(on_drag) /드래그 시작 시 UI 반응 정의 : 배경색 변경 label.drag_started.connect(lambda _: label.setStyleSheet("background:#d0ebff")) label.drag_finished.connect(lambda: label.setStyleSheet("")) 호버링 (호버링 사용시 setMouseTracking(True) 필요) def on_hover_enter(): label.setStyleSheet("background-color: #f0f0f0;") def on_hover_leave(): label.setStyleSheet("background-color: none;") label.hover_entered.connect(on_hover_enter) label.hover_left.connect(on_hover_leave) """ clicked = Signal() # 좌클릭 double_clicked = Signal() # 더블클릭 right_clicked = Signal(QPoint) # 우클릭 hover_entered = Signal() # 마우스 진입 hover_left = Signal() # 마우스 이탈 drag_started = Signal(QPoint) # 드래그 시작 위치 dragging = Signal(QPoint) # 드래그 이동량 drag_finished = Signal() # 드래그 종료 def __init__( self, text: str = "", parent=None, hover_text=None, *, enable_click: bool = True, enable_double_click: bool = True, enable_right_click: bool = False, enable_hover: bool = True, enable_drag: bool = False, enable_context_menu: bool = False, context_menu_builder=None, enable_grid_snap: bool = False, grid_size: int = 20, **kwargs ): super().__init__(text, parent) # ----------------------------- # 기능 활성화 플래그 # ----------------------------- self.enable_click = enable_click self.enable_double_click = enable_double_click self.enable_right_click = enable_right_click self.enable_hover = enable_hover self.enable_drag = enable_drag self.enable_context_menu = enable_context_menu self._context_menu_builder = context_menu_builder self.enable_grid_snap = enable_grid_snap # ----------------------------- # 드래그 / 그리드 설정 # ----------------------------- self.grid_size = grid_size self._drag_threshold = 5 # px (클릭 vs 드래그 구분) self._press_pos: QPoint | None = None self._dragging = False # ----------------------------- # UI 기본 설정 # ----------------------------- self.setCursor(Qt.PointingHandCursor) # 텍스트 중앙 정렬 self.setAlignment(Qt.AlignCenter) self._hover_text = hover_text self._hover_text_provider: callable | None = None self._hover_popup = None # hover는 mouse tracking 필수 if self.enable_hover: self.setMouseTracking(True) else: self.setMouseTracking(False) # ------------------------------------------------- # 마우스 누름 # ------------------------------------------------- def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.LeftButton: if self.enable_click: self.clicked.emit() if self.enable_drag: self._press_pos = event.pos() self._dragging = False elif event.button() == Qt.RightButton: if self.enable_right_click: self.right_clicked.emit(event.pos()) if self.enable_context_menu: self._show_context_menu(event.globalPos()) super().mousePressEvent(event) # ------------------------------------------------- # 마우스 이동 (드래그) # ------------------------------------------------- def mouseMoveEvent(self, event: QMouseEvent): if not self.enable_drag or self._press_pos is None: super().mouseMoveEvent(event) return delta = event.pos() - self._press_pos if not self._dragging and delta.manhattanLength() >= self._drag_threshold: self._dragging = True self.drag_started.emit(self._press_pos) if self._dragging: self.dragging.emit(delta) self._move_with_grid_snap(delta) super().mouseMoveEvent(event) # ------------------------------------------------- # 마우스 해제 # ------------------------------------------------- def mouseReleaseEvent(self, event: QMouseEvent): if event.button() == Qt.LeftButton and self.enable_drag: if self._dragging: self.drag_finished.emit() self._dragging = False self._press_pos = None super().mouseReleaseEvent(event) # ------------------------------------------------- # 더블클릭 # ------------------------------------------------- def mouseDoubleClickEvent(self, event: QMouseEvent): if event.button() == Qt.LeftButton and self.enable_double_click: self.double_clicked.emit() super().mouseDoubleClickEvent(event) # ------------------------------------------------- # 호버 # 사용법 (주의:provider 함수에서 DB 조회, 네트워크 요청, 긴 계산 등을 포함하면 안됨,필요한 경우 캐시+갱신 이벤트구조로 구현) # label.set_hover_text_provider( # lambda: f""" # 상태: {node.status} # 전압: {node.voltage}V # 최근 점검: {node.last_check} # """.strip() # ) # ------------------------------------------------- def enterEvent(self, event: QEnterEvent): if self.enable_hover: self.hover_entered.emit() text = self._get_hover_text() if text: self._hover_popup = HoverInfoPopup(text) pos = QCursor.pos() + QPoint(10, 10) # 마우스 커서 위치에서 약간 오프셋 self._hover_popup.move(pos) self._hover_popup.show() super().enterEvent(event) def leaveEvent(self, event: QDragLeaveEvent): if not self.enable_hover: super().leaveEvent(event) return # popup이 있고, 마우스가 popup 안에 있으면 닫지 않음 if self._hover_popup and self._hover_popup.is_mouse_inside(): return self.hover_left.emit() if self.enable_hover: if self._hover_popup: self._hover_popup.close() self._hover_popup = None super().leaveEvent(event) def set_hover_text(self, text: str): """ hover 정보 텍스트 설정 (즉시 반영) """ self._hover_text = text self._hover_text_provider = None if self._hover_popup: self._hover_popup.set_text(text) def _get_hover_text(self) -> str | None: if self._hover_text_provider: return self._hover_text_provider() return self._hover_text def clear_hover_text(self): """ hover 정보 제거 """ self._hover_text = None self._hover_text_provider = None if self._hover_popup: self._hover_popup.close() self._hover_popup = None def set_hover_text_provider(self, provider: callable): """ hover 시점에 호출되는 함수 등록 """ self._hover_text_provider = provider self._hover_text = None if self._hover_popup: self._hover_popup.set_text(provider()) # ================================================= # 내부 기능 # ================================================= def _show_context_menu(self, global_pos): if not self._context_menu_builder: return menu = QMenu(self) self._context_menu_builder(menu, self) # 컨텍스트 메뉴 빌더 호출 menu.exec(global_pos) ''' 외부에서 컨텍스트 메뉴 빌더 정의 def build_label_menu(menu: QMenu, label: ClickableLabel): menu.addAction("편집", lambda: edit_label(label)) menu.addAction("삭제", lambda: delete_label(label)) menu.addSeparator() menu.addAction("정보", lambda: show_info(label)) def edit_label(label): logger.info(f"편집: {label.text()}") def delete_label(label): label.deleteLater() def show_info(label): logger.info(f"위치: {label.pos()}") 객체 생성시 컨텍스트 메뉴 빌더 전달 label = ClickableLabel( "NODE-1", context_menu_builder=build_label_menu ) ''' def _move_with_grid_snap(self, delta: QPoint): new_pos = self.pos() + delta if self.enable_grid_snap: x = round(new_pos.x() / self.grid_size) * self.grid_size y = round(new_pos.y() / self.grid_size) * self.grid_size self.move(QPoint(x, y)) else: self.move(new_pos) class PopupTheme: LIGHT = "light" DARK = "dark" class HoverInfoPopup(QWidget): """ 마우스 호버 시 정보 팝업 표시 QWidget 사용예시 popup = HoverInfoPopup("호버 정보") popup.show() """ def __init__(self, text: str, theme=PopupTheme.DARK, parent=None): super().__init__(parent) # ----------------------------- # 테마 설정 # ----------------------------- self._theme = theme self.setWindowFlags( Qt.ToolTip | Qt.FramelessWindowHint | Qt.BypassWindowManagerHint ) self.setAttribute(Qt.WA_ShowWithoutActivating) self.apply_theme(theme) # ----------------------------- # 윈도우 설정 # ----------------------------- self.setAttribute(Qt.WA_TransparentForMouseEvents, False) # ----------------------------- # 드래그 설정 # ----------------------------- self._drag_start_pos = None # ----------------------------- # UI 설정 # ----------------------------- layout = QVBoxLayout(self) layout.setContentsMargins(8, 6, 8, 6) # ----------------------------- # 라벨 설정 # ----------------------------- self.label = QLabel(text) self.label.setTextInteractionFlags( Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard ) layout.addWidget(self.label) self.setStyleSheet(""" QWidget { background-color: #ffffe1; border: 1px solid #c8c8a9; border-radius: 4px; font-size: 12px; } """) # ----------------------------- # 드래그 이동 # ----------------------------- def mousePressEvent(self, event: QMouseEvent): if event.button() == Qt.LeftButton: self._drag_start_pos = event.globalPos() - self.frameGeometry().topLeft() def mouseMoveEvent(self, event: QMouseEvent): if self._drag_start_pos: self.move(event.globalPos() - self._drag_start_pos) def mouseReleaseEvent(self, event: QMouseEvent): self._drag_start_pos = None # ------------------------------------------------- # 텍스트 설정 # ------------------------------------------------- def set_text(self, text: str): self.label.setText(text) self.adjustSize() # 내용 바뀌면 크기 자동 조정 # ------------------------------------------------- # 마우스 위치 확인 # ------------------------------------------------- def is_mouse_inside(self) -> bool: pos = QCursor.pos() return self.geometry().contains(pos) # ------------------------------------------------- # 마우스 이탈 시 팝업 닫기 # ------------------------------------------------- def leaveEvent(self, event): self.close() super().leaveEvent(event) # ------------------------------------------------- # 테마 적용 # ------------------------------------------------- def apply_theme(self, theme: str): if theme == PopupTheme.DARK: self.setStyleSheet(""" QWidget { background-color: #2b2b2b; color: #eaeaea; border: 1px solid #555; border-radius: 6px; } """) else: self.setStyleSheet(""" QWidget { background-color: #ffffe1; color: #202020; border: 1px solid #c8c8a9; border-radius: 6px; } """)