# -*- coding: utf-8 -*- """ 커스텀 입력 필드 모듈 다양한 스타일의 커스텀 입력 필드를 정의합니다. 이 모듈은 다음 입력 필드를 제공합니다: - CustomLineEdit: 한 줄 입력 필드 - CustomTextEdit: 여러 줄 입력 필드 - CustomComboBox: 드롭다운 선택 필드 - CustomDateInput: 날짜 입력 필드 (달력 팝업) - CustomDateRangeInput: 기간 입력 필드 (달력 팝업) """ from datetime import date from typing import List, Optional, Tuple from PySide6.QtWidgets import ( QLineEdit, QTextEdit, QComboBox, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCompleter, QPushButton, QFrame ) from PySide6.QtCore import Qt, Signal, QStringListModel, QPoint from PySide6.QtGui import QFont, QColor from core.config import ConfigManager from core.logger import get_logger from ui.styles.style_manager import StyleManager logger = get_logger(__name__) class CustomLineEdit(QLineEdit): """ 커스텀 한 줄 입력 필드 현대적인 디자인의 입력 필드입니다. 플레이스홀더, 아이콘, 유효성 검사 기능을 제공합니다. Attributes: label: 입력 필드 라벨 required: 필수 입력 여부 Examples: >>> input_field = CustomLineEdit( ... placeholder="이름을 입력하세요", ... label="이름", ... required=True ... ) """ # 시그널 validation_changed = Signal(bool) # 유효성 변경 시그널 def __init__( self, parent=None, placeholder: str = "", label: str = "", required: bool = False, prefix_icon: str = "", suffix_icon: str = "", max_length: int = None, completions: List[str] = None ): super().__init__(parent) self.config = ConfigManager() self.style_manager = StyleManager() self._label = label self._required = required self._is_valid = True # 기본 설정 self.setPlaceholderText(placeholder) # 폰트 설정 (스타일 관리자 사용) input_font = self.style_manager.get_font("dialog", "input") self.setFont(input_font) if max_length: self.setMaxLength(max_length) # 자동 완성 if completions: completer = QCompleter(completions) completer.setCaseSensitivity(Qt.CaseInsensitive) self.setCompleter(completer) # 유효성 검사 연결 self.textChanged.connect(self._validate) self._apply_style() def _apply_style(self): """스타일 적용""" colors = self.style_manager.get_colors() input_font = self.style_manager.get_font("dialog", "input") # 높이 자동 계산 height = self.style_manager.calculate_input_height( font=input_font, area="dialog", style="input" ) border_color = colors['error'] if not self._is_valid else colors['input_border'] self.setStyleSheet(f""" QLineEdit {{ background-color: {colors['input_bg']}; color: {colors['input_text']}; border: 1px solid {border_color}; border-radius: 6px; padding: {height // 4}px 12px; font-family: '{input_font.family()}'; font-size: {input_font.pointSize()}pt; min-height: {height}px; }} QLineEdit:focus {{ border-color: {colors['input_focus']}; outline: none; }} QLineEdit:disabled {{ background-color: {colors['bg_tertiary']}; color: {colors['text_disabled']}; }} QLineEdit::placeholder {{ color: {colors['text_tertiary']}; }} """) def _validate(self): """유효성 검사""" if self._required and not self.text().strip(): self._is_valid = False else: self._is_valid = True self._apply_style() self.validation_changed.emit(self._is_valid) @property def is_valid(self) -> bool: """유효성 상태 반환""" return self._is_valid def set_error(self, is_error: bool): """에러 상태 설정""" self._is_valid = not is_error self._apply_style() class CustomTextEdit(QTextEdit): """ 커스텀 여러 줄 입력 필드 여러 줄의 텍스트를 입력할 수 있는 필드입니다. Examples: >>> text_edit = CustomTextEdit(placeholder="내용을 입력하세요") """ # 시그널 text_changed_signal = Signal(str) def __init__( self, parent=None, placeholder: str = "", min_height: int = 100, max_height: int = None, read_only: bool = False ): super().__init__(parent) self.config = ConfigManager() # 기본 설정 self.setPlaceholderText(placeholder) self.setFont(QFont("GmarketSans", 13)) self.setMinimumHeight(min_height) self.setReadOnly(read_only) if max_height: self.setMaximumHeight(max_height) # 시그널 연결 self.textChanged.connect(lambda: self.text_changed_signal.emit(self.toPlainText())) self._apply_style() def _apply_style(self): """스타일 적용""" theme = self.config.theme if theme == 'dark': bg = "#1e293b" border = "#334155" text = "#f8fafc" placeholder = "#64748b" focus_border = "#3b82f6" scrollbar_bg = "#1e293b" scrollbar_handle = "#475569" else: bg = "#ffffff" border = "#e2e8f0" text = "#1e293b" placeholder = "#94a3b8" focus_border = "#3b82f6" scrollbar_bg = "#f8fafc" scrollbar_handle = "#cbd5e1" self.setStyleSheet(f""" QTextEdit {{ background-color: {bg}; color: {text}; border: 2px solid {border}; border-radius: 8px; padding: 10px; font-size: 14px; }} QTextEdit:focus {{ border-color: {focus_border}; }} QScrollBar:vertical {{ background-color: {scrollbar_bg}; width: 10px; border-radius: 5px; }} QScrollBar::handle:vertical {{ background-color: {scrollbar_handle}; border-radius: 5px; min-height: 20px; }} QScrollBar::handle:vertical:hover {{ background-color: {focus_border}; }} """) def set_text(self, text: str): """텍스트 설정""" self.setPlainText(text) def get_text(self) -> str: """텍스트 반환""" return self.toPlainText() class CustomComboBox(QComboBox): """ 커스텀 드롭다운 선택 필드 목록에서 항목을 선택할 수 있는 드롭다운입니다. Examples: >>> combo = CustomComboBox( ... items=["옵션1", "옵션2", "옵션3"], ... placeholder="선택하세요" ... ) """ # 시그널 selection_changed = Signal(str) def __init__( self, parent=None, items: List[str] = None, placeholder: str = "", editable: bool = False, current_index: int = -1 ): super().__init__(parent) self.config = ConfigManager() self.style_manager = StyleManager() # 기본 설정 input_font = self.style_manager.get_font("dialog", "input") self.setFont(input_font) self.setEditable(editable) # 플레이스홀더 if placeholder: self.addItem(placeholder) self.setCurrentIndex(0) self.model().item(0).setEnabled(False) # 아이템 추가 if items: self.addItems(items) if current_index >= 0: actual_index = current_index + (1 if placeholder else 0) self.setCurrentIndex(actual_index) # 시그널 연결 self.currentTextChanged.connect(self.selection_changed.emit) self._apply_style() def _apply_style(self): """스타일 적용""" colors = self.style_manager.get_colors() input_font = self.style_manager.get_font("dialog", "input") # 높이 자동 계산 height = self.style_manager.calculate_input_height( font=input_font, area="dialog", style="input" ) self.setStyleSheet(f""" QComboBox {{ background-color: {colors['input_bg']}; color: {colors['input_text']}; border: 1px solid {colors['input_border']}; border-radius: 6px; padding: {height // 4}px 12px; font-family: '{input_font.family()}'; font-size: {input_font.pointSize()}pt; min-height: {height}px; }} QComboBox:hover {{ border-color: {colors['input_focus']}; }} QComboBox:focus {{ border-color: {colors['input_focus']}; outline: none; }} QComboBox::drop-down {{ border: none; width: 25px; }} QComboBox::down-arrow {{ image: none; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid {colors['input_text']}; margin-right: 5px; }} QComboBox QAbstractItemView {{ background-color: {colors['input_bg']}; color: {colors['input_text']}; border: 1px solid {colors['input_border']}; border-radius: 8px; padding: 4px; selection-background-color: {colors['accent']}; selection-color: {colors['btn_primary_text']}; }} QComboBox QAbstractItemView::item {{ padding: 8px 12px; border-radius: 4px; }} QComboBox QAbstractItemView::item:hover {{ background-color: {colors['bg_hover']}; }} """) def get_selected_value(self) -> Optional[str]: """선택된 값 반환""" text = self.currentText() # 플레이스홀더 체크 if self.currentIndex() == 0 and not self.itemData(0): return None return text def set_selected_value(self, value: str): """값으로 선택""" index = self.findText(value) if index >= 0: self.setCurrentIndex(index) def clear_selection(self): """선택 초기화""" if self.count() > 0: self.setCurrentIndex(0) class LabeledInput(QWidget): """ 라벨이 있는 입력 필드 컨테이너 라벨과 입력 필드를 함께 제공합니다. """ def __init__( self, label: str, input_widget: QWidget, parent=None, required: bool = False, horizontal: bool = False ): super().__init__(parent) self.config = ConfigManager() self.style_manager = StyleManager() self.input_widget = input_widget self._setup_ui(label, required, horizontal) def _setup_ui(self, label: str, required: bool, horizontal: bool): """UI 설정""" if horizontal: layout = QHBoxLayout(self) else: layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(6) # 라벨 label_text = f"{label} *" if required else label self.label = QLabel(label_text) # 폰트 설정 (스타일 관리자 사용) label_font = self.style_manager.get_font("dialog", "label") self.label.setFont(label_font) # 높이 계산 label_height = self.style_manager.calculate_label_height( font=label_font, area="dialog", style="label" ) # 색상 설정 colors = self.style_manager.get_colors() text_color = colors['text_primary'] required_color = colors['error'] self.label.setStyleSheet(f""" QLabel {{ color: {text_color}; font-family: '{label_font.family()}'; font-size: {label_font.pointSize()}pt; min-height: {label_height}px; }} """) layout.addWidget(self.label) layout.addWidget(self.input_widget, 1 if horizontal else 0) def get_value(self): """입력 값 반환""" if isinstance(self.input_widget, QLineEdit): return self.input_widget.text() elif isinstance(self.input_widget, QTextEdit): return self.input_widget.toPlainText() elif isinstance(self.input_widget, QComboBox): return self.input_widget.currentText() return None def set_value(self, value): """입력 값 설정""" if isinstance(self.input_widget, QLineEdit): self.input_widget.setText(str(value) if value else "") elif isinstance(self.input_widget, QTextEdit): self.input_widget.setPlainText(str(value) if value else "") elif isinstance(self.input_widget, QComboBox): self.input_widget.setCurrentText(str(value) if value else "") class CalendarPopup(QFrame): """ 달력 팝업 프레임 날짜 입력 필드에서 사용하는 팝업 달력입니다. """ date_selected = Signal(object) # date range_selected = Signal(object, object) # start_date, end_date closed = Signal() def __init__(self, parent=None, range_mode: bool = False): super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint) self.config = ConfigManager() self._range_mode = range_mode self._setup_ui() self._apply_style() def _setup_ui(self): """UI 설정""" from ui.components.custom_calendar import CustomCalendar layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) self.calendar = CustomCalendar(show_range_toggle=self._range_mode) self.calendar.date_selected.connect(self._on_date_selected) self.calendar.range_selected.connect(self._on_range_selected) layout.addWidget(self.calendar) def _apply_style(self): """스타일 적용""" theme = self.config.theme if theme == 'dark': bg = "#1e293b" border = "#334155" else: bg = "#ffffff" border = "#e2e8f0" self.setStyleSheet(f""" CalendarPopup {{ background-color: {bg}; border: 1px solid {border}; border-radius: 12px; }} """) def _on_date_selected(self, selected_date: date): """날짜 선택됨""" self.date_selected.emit(selected_date) if not self._range_mode: self.close() def _on_range_selected(self, start: date, end: date): """기간 선택됨""" self.range_selected.emit(start, end) self.close() def set_date(self, d: date): """날짜 설정""" self.calendar.set_date(d) def set_range(self, start: date, end: date): """기간 설정""" self.calendar.set_range(start, end) def closeEvent(self, event): """닫기 이벤트""" self.closed.emit() super().closeEvent(event) class CustomDateInput(QWidget): """ 커스텀 날짜 입력 필드 클릭 시 달력 팝업이 표시됩니다. Signals: date_changed: 날짜 변경 시그널 (date) Examples: >>> date_input = CustomDateInput(placeholder="날짜 선택") >>> date_input.date_changed.connect(self.on_date_changed) """ date_changed = Signal(object) # date def __init__( self, parent=None, placeholder: str = "날짜 선택", initial_date: date = None ): super().__init__(parent) self.config = ConfigManager() self._date = initial_date or date.today() self._popup: Optional[CalendarPopup] = None self._setup_ui(placeholder) def _setup_ui(self, placeholder: str): """UI 설정""" layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) theme = self.config.theme is_dark = theme == 'dark' # 날짜 표시 버튼 (클릭 가능) self.date_btn = QPushButton() self.date_btn.setFont(QFont("GmarketSans", 13)) self.date_btn.setCursor(Qt.PointingHandCursor) self.date_btn.clicked.connect(self._show_calendar) self._update_display() self.date_btn.setStyleSheet(f""" QPushButton {{ background-color: {'#1e293b' if is_dark else '#ffffff'}; color: {'#f8fafc' if is_dark else '#1e293b'}; border: 2px solid {'#334155' if is_dark else '#e2e8f0'}; border-radius: 8px; padding: 10px 14px; text-align: left; }} QPushButton:hover {{ border-color: #3b82f6; }} QPushButton:focus {{ border-color: #3b82f6; }} """) layout.addWidget(self.date_btn) def _update_display(self): """날짜 표시 업데이트""" if self._date: text = f"📅 {self._date.year}.{self._date.month:02d}.{self._date.day:02d}" else: text = "📅 날짜 선택" self.date_btn.setText(text) def _show_calendar(self): """달력 팝업 표시""" if self._popup and self._popup.isVisible(): self._popup.close() return self._popup = CalendarPopup(self, range_mode=False) self._popup.date_selected.connect(self._on_date_selected) self._popup.closed.connect(self._on_popup_closed) if self._date: self._popup.set_date(self._date) # 팝업 위치 계산 pos = self.mapToGlobal(QPoint(0, self.height() + 4)) self._popup.move(pos) self._popup.show() def _on_date_selected(self, selected_date: date): """날짜 선택됨""" self._date = selected_date self._update_display() self.date_changed.emit(selected_date) def _on_popup_closed(self): """팝업 닫힘""" self._popup = None def get_date(self) -> Optional[date]: """선택된 날짜 반환""" return self._date def set_date(self, d: date): """날짜 설정""" self._date = d self._update_display() class CustomDateRangeInput(QWidget): """ 커스텀 기간 입력 필드 클릭 시 기간 선택이 가능한 달력 팝업이 표시됩니다. Signals: range_changed: 기간 변경 시그널 (start: date, end: date) Examples: >>> range_input = CustomDateRangeInput() >>> range_input.range_changed.connect(self.on_range_changed) """ range_changed = Signal(object, object) # start_date, end_date def __init__( self, parent=None, placeholder: str = "기간 선택", initial_start: date = None, initial_end: date = None ): super().__init__(parent) self.config = ConfigManager() self._start_date = initial_start self._end_date = initial_end self._popup: Optional[CalendarPopup] = None self._setup_ui(placeholder) def _setup_ui(self, placeholder: str): """UI 설정""" layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) theme = self.config.theme is_dark = theme == 'dark' # 기간 표시 버튼 (클릭 가능) self.range_btn = QPushButton() self.range_btn.setFont(QFont("GmarketSans", 13)) self.range_btn.setCursor(Qt.PointingHandCursor) self.range_btn.clicked.connect(self._show_calendar) self._update_display() self.range_btn.setStyleSheet(f""" QPushButton {{ background-color: {'#1e293b' if is_dark else '#ffffff'}; color: {'#f8fafc' if is_dark else '#1e293b'}; border: 2px solid {'#334155' if is_dark else '#e2e8f0'}; border-radius: 8px; padding: 10px 14px; text-align: left; }} QPushButton:hover {{ border-color: #3b82f6; }} QPushButton:focus {{ border-color: #3b82f6; }} """) layout.addWidget(self.range_btn) def _update_display(self): """기간 표시 업데이트""" if self._start_date and self._end_date: text = ( f"📅 {self._start_date.year}.{self._start_date.month:02d}.{self._start_date.day:02d}" f" ~ {self._end_date.year}.{self._end_date.month:02d}.{self._end_date.day:02d}" ) else: text = "📅 기간 선택" self.range_btn.setText(text) def _show_calendar(self): """달력 팝업 표시""" if self._popup and self._popup.isVisible(): self._popup.close() return self._popup = CalendarPopup(self, range_mode=True) self._popup.range_selected.connect(self._on_range_selected) self._popup.closed.connect(self._on_popup_closed) if self._start_date and self._end_date: self._popup.set_range(self._start_date, self._end_date) # 팝업 위치 계산 pos = self.mapToGlobal(QPoint(0, self.height() + 4)) self._popup.move(pos) self._popup.show() def _on_range_selected(self, start: date, end: date): """기간 선택됨""" self._start_date = start self._end_date = end self._update_display() self.range_changed.emit(start, end) def _on_popup_closed(self): """팝업 닫힘""" self._popup = None def get_range(self) -> Tuple[Optional[date], Optional[date]]: """선택된 기간 반환""" return self._start_date, self._end_date def set_range(self, start: date, end: date): """기간 설정""" self._start_date = start self._end_date = end self._update_display()