feat(ui): Steam-style conflict dialog
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:
+165
-48
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user