Fixes for version pinning in GUI

- Replace icons with pins rather than checkboxes
- Add a tooltip that says "Pin version"
- Fix "Active" status bubble UX issues
This commit is contained in:
daijro
2026-07-15 09:41:41 -05:00
parent 26ec23ab40
commit 9aebec17e5
2 changed files with 216 additions and 34 deletions
+136 -8
View File
@@ -48,10 +48,12 @@ class Worker(QThread):
class DownloadWorker(Worker):
def __init__(self, repo_config, version):
def __init__(self, repo_config, version, activate_after_install=False):
super().__init__()
self.repo_config = repo_config
self.version = version
self.activate_after_install = activate_after_install
self.relative_path = None
def run(self):
try:
@@ -81,7 +83,7 @@ class DownloadWorker(Worker):
if sys.platform != 'win32':
os.system(f'chmod -R 755 {shlex.quote(str(path))}')
set_active(f"browsers/{repo_name}/{folder}")
self.relative_path = f"browsers/{repo_name}/{folder}"
self.done.emit(True, f"Installed v{self.version.version.full_string}")
except Exception as e:
msg = str(e)
@@ -310,6 +312,7 @@ class Backend(QObject):
debugChanged = Signal()
currentRepoChanged = Signal()
channelPrompt = Signal(int, str, str)
installedPrompt = Signal(int, str, str, bool, str)
def __init__(self):
super().__init__()
@@ -650,6 +653,45 @@ class Backend(QObject):
self._refresh()
self.infoChanged.emit()
@Slot(int)
def unpinVersion(self, index):
item = self._version_model.get(index)
if not item or not item.is_pinned:
return
cfg = load_config()
cfg.pop('pinned', None)
cfg.pop('pinned_sha', None)
save_config(cfg)
self._refresh()
self.infoChanged.emit()
@Slot(int)
def followVersionChannel(self, index):
item = self._version_model.get(index)
if not item or not item.version_data or not item.installed_data:
return
ctype = "prerelease" if item.is_prerelease else "stable"
repo_name = self._current_repo.name
cfg = load_config()
cfg['channel'] = repo_name if ctype == "stable" else f"{repo_name}/{ctype}"
cfg.pop('pinned', None)
cfg.pop('pinned_sha', None)
cfg.update(
{
'active_repo': repo_name,
'active_build': item.version_data.version.build,
'active_version': item.version_data.version.version,
}
)
save_config(cfg)
set_active(item.installed_data.relative_path)
self._refresh()
self.infoChanged.emit()
@Slot(int)
def setFollowedChannel(self, index):
_, keys, _ = self._build_channels()
@@ -702,31 +744,50 @@ class Backend(QObject):
break
self._refresh()
self.infoChanged.emit()
for idx, item in enumerate(self._version_model._items):
if item.is_header:
continue
if item.is_prerelease == is_pre:
self._selected = idx
self.selectionChanged.emit()
if item.installed_data:
set_active(item.installed_data.relative_path)
self._refresh()
self._selected = idx
self.selectionChanged.emit()
break
self.infoChanged.emit()
@Slot()
def installSelected(self):
item = self._version_model.get(self._selected)
if item and not item.is_header and not item.is_installed:
self._run_worker(DownloadWorker(self._current_repo, item.version_data), self._on_done)
worker = DownloadWorker(
self._current_repo,
item.version_data,
activate_after_install=item.is_active,
)
self._run_worker(worker, self._on_done)
@Slot()
def uninstallSelected(self):
item = self._version_model.get(self._selected)
if not item or not item.is_installed or not item.installed_data:
return
installed = item.installed_data
was_active = item.is_active
try:
remove_version(item.installed_data.path)
remove_version(installed.path)
if was_active:
cfg = load_config()
cfg.update(
{
'active_repo': self._current_repo.name,
'active_build': installed.version.build,
'active_version': installed.version.version,
}
)
save_config(cfg)
self._set_status(f"Uninstalled {item.display}", "#2ecc71")
self._refresh()
self.infoChanged.emit()
@@ -965,6 +1026,20 @@ class Backend(QObject):
return v.sha256 == latest_sha.get(v.version.full_string)
repo_key = get_repo_name(self._current_repo.repo)
channel = cfg.get('channel') or get_default_channel()
_, active_channel = (channel.split('/', 1) + ['stable'])[:2]
def _is_active(v, inst):
if inst and inst.is_active:
return True
return (
str(cfg.get('active_repo') or '').lower() == repo_key
and cfg.get('active_build') == v.version.build
and cfg.get('active_version') == v.version.version
and v.is_prerelease == (active_channel == 'prerelease')
and v.sha256 == latest_sha.get(v.version.full_string)
)
installed_list = [iv for iv in list_installed() if iv.repo_name == repo_key]
row_inst, extras = classify_installs(versions, installed_list)
inst_by_id = {id(v): inst for v, inst in zip(versions, row_inst)}
@@ -995,7 +1070,7 @@ class Backend(QObject):
f"v{v.version.version}",
v.version.build,
is_prerelease=is_prerelease,
is_active=bool(inst and inst.is_active),
is_active=_is_active(v, inst),
is_pinned=_is_pinned(v),
is_installed=bool(inst),
version_data=v,
@@ -1063,13 +1138,66 @@ class Backend(QObject):
worker.start()
def _on_done(self, ok, msg):
worker = self._worker
self._busy = False
self._progress = -1
self.busyChanged.emit()
self._set_status(msg if ok else f"Error: {msg}", "#2ecc71" if ok else "#e74c3c")
if ok:
if (
isinstance(worker, DownloadWorker)
and worker.activate_after_install
and worker.relative_path
):
set_active(worker.relative_path)
self._channel_data = None
self._refresh()
if isinstance(worker, DownloadWorker):
target_idx = -1
for idx, item in enumerate(self._version_model._items):
version = item.version_data
if not version:
continue
if (
version.version.full_string == worker.version.version.full_string
and version.sha256 == worker.version.sha256
):
target_idx = idx
break
if target_idx >= 0:
self._selected = target_idx
self.selectionChanged.emit()
if not worker.activate_after_install:
versions = get_cached_versions(worker.repo_config.name)
latest = next(
(
version
for version in versions
if version.is_prerelease == worker.version.is_prerelease
),
None,
)
is_latest = bool(
latest
and latest.version.full_string == worker.version.version.full_string
and latest.sha256 == worker.version.sha256
)
channel = worker.repo_config.name
if worker.version.is_prerelease:
channel += " (Prerelease)"
item = self._version_model.get(target_idx)
self.installedPrompt.emit(
target_idx,
item.display,
item.build,
is_latest,
channel,
)
self.infoChanged.emit()
self._load_spoof_from_cache()
self.debugChanged.emit()
+80 -26
View File
@@ -152,24 +152,42 @@ ApplicationWindow {
}
}
component Check: Rectangle {
property bool on: false
component PinButton: Item {
id: pinButton
property bool pinned: false
property string pinToolTip: "Pin version"
property string unpinToolTip: "Unpin version"
signal clicked
width: s3
height: s3
radius: s1
color: on ? c.accent : "transparent"
border.color: on ? c.accent : c.muted
border.width: 1
width: s4
height: s4
Text {
Icon {
anchors.centerIn: parent
text: "\uE73E"
color: c.bg
font.family: fontIcon
font.pixelSize: Math.round(8 * scale)
visible: parent.on
icon: "\uE840"
color: pinButton.pinned ? c.accent : c.muted
opacity: pinButton.enabled ? 1 : 0.4
}
Icon {
anchors.centerIn: parent
icon: "\uE842"
color: c.accent
opacity: pinButton.enabled ? 1 : 0.4
visible: pinButton.pinned
}
MouseArea {
id: pinMa
anchors.fill: parent
hoverEnabled: true
cursorShape: pinButton.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (pinButton.enabled) pinButton.clicked()
}
ToolTip.visible: pinMa.containsMouse && pinButton.enabled
ToolTip.text: pinButton.pinned ? pinButton.unpinToolTip : pinButton.pinToolTip
ToolTip.delay: 500
}
component Input: Rectangle {
@@ -335,14 +353,16 @@ ApplicationWindow {
property string dlgAct: ""
property string dlgVer: ""
property string dlgBuild: ""
property string dlgChannel: ""
property int dlgIdx: -1
function showDlg(act, idx, ver, build) {
function showDlg(act, idx, ver, build, channel) {
backend.selectVersion(idx)
dlgAct = act
dlgIdx = idx
dlgVer = ver
dlgBuild = build
dlgChannel = channel || ""
}
Rectangle {
@@ -377,7 +397,9 @@ ApplicationWindow {
Bold {
text: (dlgAct === "install" || dlgAct === "promptInstall") ? "Install"
: dlgAct === "uninstall" ? "Uninstall" : "Set Active"
: dlgAct === "uninstall" ? "Uninstall"
: dlgAct === "promptPin" ? "Pin Version"
: dlgAct === "promptFollow" ? "Follow Channel" : "Set Active"
font.pixelSize: textMd
}
@@ -386,6 +408,10 @@ ApplicationWindow {
wrapMode: Text.Wrap
text: dlgAct === "promptInstall"
? "Camoufox " + dlgVer + "-" + dlgBuild + " is now active but not installed. Install it now?"
: dlgAct === "promptPin"
? "Camoufox " + dlgVer + "-" + dlgBuild + " is installed but not active. Would you like to pin this version?"
: dlgAct === "promptFollow"
? "Camoufox " + dlgVer + "-" + dlgBuild + " is installed but not active. Would you like to follow " + dlgChannel + " for updates?"
: (dlgAct === "install" ? "Install" : dlgAct === "uninstall" ? "Remove" : "Set") +
" Camoufox " + dlgVer + "-" + dlgBuild +
(dlgAct === "install" ? "?" : dlgAct === "uninstall" ? " from disk?" : " as active?")
@@ -406,13 +432,25 @@ ApplicationWindow {
spacing: s2
Btn {
text: dlgAct === "promptInstall" ? "Skip" : "Cancel"
text: (dlgAct === "promptInstall" || dlgAct === "promptPin" || dlgAct === "promptFollow") ? "Skip" : "Cancel"
onClicked: dlgAct = ""
}
Btn {
visible: dlgAct === "promptFollow"
text: "Pin Version"
accent: c.accent
onClicked: {
backend.setActive(dlgIdx)
dlgAct = ""
}
}
Btn {
text: (dlgAct === "install" || dlgAct === "promptInstall") ? "Install"
: dlgAct === "uninstall" ? "Uninstall" : "Set Active"
: dlgAct === "uninstall" ? "Uninstall"
: dlgAct === "promptPin" ? "Pin Version"
: dlgAct === "promptFollow" ? "Follow Channel" : "Set Active"
accent: dlgAct === "uninstall" ? c.err
: (dlgAct === "install" || dlgAct === "promptInstall") ? c.ok : c.accent
onClicked: {
@@ -420,6 +458,10 @@ ApplicationWindow {
backend.installSelected()
} else if (dlgAct === "uninstall") {
backend.uninstallSelected()
} else if (dlgAct === "promptPin") {
backend.setActive(dlgIdx)
} else if (dlgAct === "promptFollow") {
backend.followVersionChannel(dlgIdx)
} else if (dlgAct === "channel") {
backend.confirmFollowChannel()
if (backend.canInstall) {
@@ -449,6 +491,9 @@ ApplicationWindow {
function onChannelPrompt(idx, display, build) {
showDlg("channel", idx, display, build)
}
function onInstalledPrompt(idx, display, build, latest, channel) {
showDlg(latest ? "promptFollow" : "promptPin", idx, display, build, channel)
}
}
// Layout
@@ -601,12 +646,18 @@ ApplicationWindow {
Row {
spacing: s2
Check { on: backend.followedChannel === backend.channelKeys[index]; anchors.verticalCenter: parent.verticalCenter }
PinButton {
pinned: backend.followedChannel === backend.channelKeys[index]
pinToolTip: "Follow channel"
unpinToolTip: "Following channel"
anchors.verticalCenter: parent.verticalCenter
onClicked: backend.setFollowedChannel(index)
}
T { text: (backend.followedChannel === backend.channelKeys[index] ? "Following " : "Follow ") + modelData; anchors.verticalCenter: parent.verticalCenter }
}
Muted {
leftPadding: s3 + s2
leftPadding: s4 + s2
text: backend.channelLatest[index] ? ("Latest: " + backend.channelLatest[index]) : "(sync first)"
font.pixelSize: Math.round(10 * scale)
}
@@ -752,12 +803,15 @@ ApplicationWindow {
spacing: s3
visible: !model.isHeader
Check {
on: model.isPinned
MouseArea {
anchors.fill: parent
cursorShape: model.note === "" ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (model.note === "") showDlg("setActive", index, model.display, model.build)
PinButton {
pinned: model.isPinned
enabled: model.note === ""
onClicked: {
if (model.isPinned) {
backend.unpinVersion(index)
} else {
showDlg("setActive", index, model.display, model.build)
}
}
}