diff --git a/cloud_sync/ui_qt.py b/cloud_sync/ui_qt.py index 7878784..9086d2a 100644 --- a/cloud_sync/ui_qt.py +++ b/cloud_sync/ui_qt.py @@ -278,77 +278,194 @@ def prompt_login_qt() -> str | None: # --------------------------------------------------------------------------- +_CONFLICT_QSS = """ +QDialog { background: #1b2838; } +QLabel#title { + color: white; + font-size: 20pt; + font-weight: bold; + letter-spacing: 2px; +} +QLabel#warning { + color: #1b2838; + background: #d6dde6; + border-radius: 16px; + font-size: 18pt; + font-weight: bold; + qproperty-alignment: AlignCenter; + min-width: 32px; + max-width: 32px; + min-height: 32px; + max-height: 32px; +} +QLabel#body { + color: #a9b3bf; + font-size: 10pt; +} +QLabel#note { + color: #7d8a99; + font-size: 9pt; +} +QFrame#card { + background: #2a3f5a; + border: 1px solid transparent; + border-radius: 4px; +} +QFrame#card:hover { + background: #34507a; + border: 1px solid #5b8fc7; +} +QLabel#cardTitle { + color: white; + font-size: 13pt; + font-weight: bold; + background: transparent; +} +QLabel#cardSubtitle { + color: #a9b3bf; + font-size: 9pt; + background: transparent; +} +QLabel#cardIcon { + color: white; + font-size: 24pt; + background: transparent; +} +QPushButton#cancel { + background: #2a475e; + color: white; + border: 1px solid #4a6580; + border-radius: 2px; + padding: 8px 24px; + font-size: 10pt; +} +QPushButton#cancel:hover { background: #355d7d; } +QPushButton#cancel:pressed { background: #1f3548; } +""" + + def prompt_conflict_qt( - local_summary: str, - remote_summary: str, + local_modified: str, + remote_modified: str, + save_label: str = "Minecraft save", ) -> str: - """Ask how to resolve a divergence. + """Steam-Cloud-Conflict-styled dialog. + + Args: + local_modified: human-readable "Saturday, February 12 at 12:28 AM" + remote_modified: same, but for the cloud snapshot + save_label: noun phrase for body copy (e.g. "Minecraft save"). Returns one of: ``"keep_local"``, ``"use_remote"``, ``"cancel"``. + Visually mirrors Steam's Cloud Conflict modal: header with warning + glyph + uppercase title, two large card buttons (cloud / local), + Cancel bottom-right. Card click commits and dismisses. """ - QtWidgets, QtCore, _ = import_qt() + QtWidgets, QtCore, Signal = import_qt() app_existed = QtWidgets.QApplication.instance() is not None app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv) if not app_existed: _apply_prism_dark(app) + class _Card(QtWidgets.QFrame): + clicked = Signal() + + def __init__(self, glyph: str, title: str, subtitle: str) -> None: + super().__init__() + self.setObjectName("card") + self.setCursor(QtCore.Qt.CursorShape.PointingHandCursor) + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + row = QtWidgets.QHBoxLayout(self) + row.setContentsMargins(18, 14, 18, 14) + row.setSpacing(16) + icon = QtWidgets.QLabel(glyph) + icon.setObjectName("cardIcon") + icon.setFixedWidth(40) + icon.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + row.addWidget(icon) + text_col = QtWidgets.QVBoxLayout() + text_col.setSpacing(2) + t = QtWidgets.QLabel(title) + t.setObjectName("cardTitle") + s = QtWidgets.QLabel(subtitle) + s.setObjectName("cardSubtitle") + text_col.addWidget(t) + text_col.addWidget(s) + row.addLayout(text_col, 1) + + def mousePressEvent(self, _event: Any) -> None: # noqa: N802 + self.clicked.emit() + dialog = QtWidgets.QDialog() dialog.setWindowTitle("Cloud sync — conflict") - dialog.setFixedSize(520, 320) + dialog.setFixedSize(640, 460) + dialog.setStyleSheet(_CONFLICT_QSS) - layout = QtWidgets.QVBoxLayout(dialog) - layout.setContentsMargins(20, 20, 20, 20) - layout.setSpacing(10) + outer = QtWidgets.QVBoxLayout(dialog) + outer.setContentsMargins(28, 24, 28, 20) + outer.setSpacing(14) - title = QtWidgets.QLabel("Local and cloud diverged") - font = title.font() - font.setBold(True) - font.setPointSize(font.pointSize() + 2) - title.setFont(font) - layout.addWidget(title) + header = QtWidgets.QHBoxLayout() + header.setSpacing(12) + warning = QtWidgets.QLabel("!") + warning.setObjectName("warning") + header.addWidget(warning) + title = QtWidgets.QLabel("CLOUD CONFLICT") + title.setObjectName("title") + header.addWidget(title) + header.addStretch(1) + outer.addLayout(header) - summary = QtWidgets.QLabel( - f"Local changes since last sync:\n {local_summary}\n\n" - f"Cloud has newer changes from another machine:\n {remote_summary}\n\n" - f"What should we do?" + body = QtWidgets.QLabel( + f"Your local {save_label} conflicts with what is stored in the cloud. " + f"Whichever save data you choose to keep will be synced to this device " + f"and the cloud. The option you choose not to keep will be overwritten." ) - summary.setWordWrap(True) - layout.addWidget(summary) + body.setObjectName("body") + body.setWordWrap(True) + outer.addWidget(body) - keep_local = QtWidgets.QRadioButton( - "Keep local — skip pull. Next exit will overwrite cloud." - ) - use_remote = QtWidgets.QRadioButton( - "Use cloud — overwrite local files with the cloud version." - ) - cancel_launch = QtWidgets.QRadioButton( - "Cancel launch — don't start Minecraft until I resolve manually." - ) - keep_local.setChecked(True) - layout.addWidget(keep_local) - layout.addWidget(use_remote) - layout.addWidget(cancel_launch) + outer.addSpacing(6) - layout.addStretch(1) + cloud_card = _Card("☁", "Cloud Save", f"Modified {remote_modified}") + local_card = _Card("▢", "Local Save", f"Modified {local_modified}") + outer.addWidget(cloud_card) + outer.addWidget(local_card) - button_row = QtWidgets.QHBoxLayout() - button_row.addStretch(1) - confirm = QtWidgets.QPushButton("Continue") - confirm.setDefault(True) - button_row.addWidget(confirm) - layout.addLayout(button_row) + outer.addStretch(1) + + foot = QtWidgets.QHBoxLayout() + note = QtWidgets.QLabel( + "Note: You will need to choose a version to keep before launching the game." + ) + note.setObjectName("note") + note.setWordWrap(True) + foot.addWidget(note, 1) + cancel = QtWidgets.QPushButton("Cancel") + cancel.setObjectName("cancel") + foot.addWidget(cancel) + outer.addLayout(foot) chosen: dict[str, str] = {"value": "cancel"} - def on_confirm() -> None: - if keep_local.isChecked(): - chosen["value"] = "keep_local" - elif use_remote.isChecked(): - chosen["value"] = "use_remote" - else: - chosen["value"] = "cancel" + def pick_remote() -> None: + chosen["value"] = "use_remote" dialog.accept() - confirm.clicked.connect(on_confirm) + def pick_local() -> None: + chosen["value"] = "keep_local" + dialog.accept() + + def pick_cancel() -> None: + chosen["value"] = "cancel" + dialog.reject() + + cloud_card.clicked.connect(pick_remote) + local_card.clicked.connect(pick_local) + cancel.clicked.connect(pick_cancel) + dialog.exec() return chosen["value"]