feat(ui): Steam-style conflict dialog
CI / test (3.10) (push) Successful in 7s
CI / test (3.11) (push) Successful in 7s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

Replaces the radio+continue layout with a Steam Cloud Conflict
lookalike: uppercase title with circled-! glyph, three-line body
copy, two large card-buttons (Cloud Save / Local Save) each showing
icon + label + 'Modified <date>', Cancel bottom-right. Card click
commits the choice and dismisses — no separate Continue button.

Background switched to Steam-blue (#1b2838) instead of Prism dark
so the dialog reads as a 'pay attention' moment rather than another
status pane.

Card glyphs are unicode placeholders (☁ / ▢) — easy SVG swap later
if we want crisp HDD/cloud icons.

API change:
  prompt_conflict_qt(local_summary, remote_summary)
    → prompt_conflict_qt(local_modified, remote_modified, save_label)
  Caller now passes pre-formatted timestamp strings + an optional
  noun phrase ('Minecraft save').
This commit is contained in:
2026-06-04 23:22:26 +02:00
parent ff96bf4b70
commit 98c63a261b
+164 -47
View File
@@ -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():
def pick_remote() -> None:
chosen["value"] = "use_remote"
else:
chosen["value"] = "cancel"
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"]