handOver2/ui/components/custom_input.py

781 lines
23 KiB
Python

# -*- 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()