Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
506b66cfdd | ||
|
|
078343ff96 | ||
|
|
753822c242 |
@@ -0,0 +1,16 @@
|
||||
<!--
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
-->
|
||||
|
||||
## Reason for the change
|
||||
|
||||
<!-- What problem does this solve, or what does it add? -->
|
||||
|
||||
## Test plan
|
||||
|
||||
<!-- How did you verify this? Build/run steps, commands, etc. -->
|
||||
|
||||
## Screenshots
|
||||
|
||||
<!-- If this changes UI, include a before/after. -->
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Build and Publish Flatpak
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
reuse:
|
||||
name: REUSE compliance
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: REUSE Compliance Check
|
||||
uses: fsfe/reuse-action@v3
|
||||
|
||||
clang-format:
|
||||
name: clang-format
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Configure (generates the KDE .clang-format style)
|
||||
run: cmake -B build -G Ninja -DBUILD_TESTING=OFF
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
find src autotests -name '*.h' -o -name '*.cpp' | \
|
||||
xargs clang-format --dry-run --Werror
|
||||
@@ -0,0 +1,41 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Build and run tests
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Ships the KDE Platform/Sdk + flatpak-builder. Bump the tag to match
|
||||
# runtime-version in the manifest.
|
||||
image: ghcr.io/flathub-infra/flatpak-github-actions:kde-6.10
|
||||
options: --privileged
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build from the checked-out commit
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import re
|
||||
p = "io.github.toservetheking.Kareer.yml"
|
||||
s = open(p).read()
|
||||
s = re.sub(r" sources:.*\Z",
|
||||
" sources:\n - type: dir\n path: .\n",
|
||||
s, flags=re.S)
|
||||
open(p, "w").write(s)
|
||||
print(s)
|
||||
PY
|
||||
|
||||
- name: Build and run tests
|
||||
run: |
|
||||
flatpak-builder --user --disable-rofiles-fuse --force-clean \
|
||||
builddir io.github.toservetheking.Kareer.yml
|
||||
@@ -8,3 +8,5 @@
|
||||
# Editor / misc
|
||||
*.user
|
||||
*.autosave
|
||||
# Generated by ECM's KDEClangFormat module
|
||||
/.clang-format
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Kareer - a Kirigami/Qt job application tracker
|
||||
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
|
||||
project(kareer VERSION 0.1.0 LANGUAGES CXX)
|
||||
project(kareer VERSION 0.1.3 LANGUAGES CXX)
|
||||
|
||||
set(REQUIRED_QT_VERSION 6.6.0)
|
||||
set(REQUIRED_KF_VERSION 6.5.0)
|
||||
@@ -15,7 +17,10 @@ include(KDEInstallDirs)
|
||||
include(KDECMakeSettings)
|
||||
include(KDECompilerSettings NO_POLICY_SCOPE)
|
||||
include(ECMSetupVersion)
|
||||
include(ECMInstallIcons)
|
||||
include(FeatureSummary)
|
||||
include(KDEClangFormat)
|
||||
include(KDEGitCommitHooks)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
@@ -28,6 +33,9 @@ find_package(Qt6 ${REQUIRED_QT_VERSION} REQUIRED COMPONENTS
|
||||
Widgets
|
||||
Qml
|
||||
Quick
|
||||
# QtQuick.Shapes runtime QML module (SankeyDiagram.qml); configure-time
|
||||
# guard only, nothing links against it.
|
||||
QuickShapesPrivate
|
||||
QuickControls2
|
||||
Sql
|
||||
Test
|
||||
@@ -38,6 +46,19 @@ find_package(KF6 ${REQUIRED_KF_VERSION} REQUIRED COMPONENTS
|
||||
CoreAddons
|
||||
IconThemes
|
||||
Crash
|
||||
ColorScheme
|
||||
)
|
||||
|
||||
find_package(KF6Kirigami ${REQUIRED_KF_VERSION} REQUIRED)
|
||||
set_package_properties(KF6Kirigami PROPERTIES
|
||||
TYPE REQUIRED
|
||||
DESCRIPTION "KDE's next-gen UI framework"
|
||||
)
|
||||
|
||||
find_package(KF6KirigamiAddons REQUIRED)
|
||||
set_package_properties(KF6KirigamiAddons PROPERTIES
|
||||
TYPE REQUIRED
|
||||
DESCRIPTION "Add-on components for Kirigami"
|
||||
)
|
||||
|
||||
ecm_setup_version(${PROJECT_VERSION}
|
||||
@@ -45,6 +66,7 @@ ecm_setup_version(${PROJECT_VERSION}
|
||||
VERSION_HEADER "${CMAKE_CURRENT_BINARY_DIR}/kareer-version.h"
|
||||
)
|
||||
|
||||
add_subdirectory(icons)
|
||||
add_subdirectory(src)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
@@ -55,11 +77,11 @@ install(PROGRAMS io.github.toservetheking.Kareer.desktop
|
||||
DESTINATION ${KDE_INSTALL_APPDIR})
|
||||
install(FILES io.github.toservetheking.Kareer.metainfo.xml
|
||||
DESTINATION ${KDE_INSTALL_METAINFODIR})
|
||||
install(FILES icons/io.github.toservetheking.Kareer.svg
|
||||
DESTINATION ${KDE_INSTALL_ICONDIR}/hicolor/scalable/apps)
|
||||
install(FILES LICENSES/GPL-3.0-or-later.txt
|
||||
DESTINATION ${KDE_INSTALL_DATAROOTDIR}/licenses/${PROJECT_NAME})
|
||||
|
||||
# Translations: add a po/ directory and re-enable ki18n_install(po) once present.
|
||||
|
||||
kde_configure_git_pre_commit_hook(CHECKS CLANG-FORMAT)
|
||||
|
||||
feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": 6,
|
||||
"cmakeMinimumRequired": {
|
||||
"major": 3,
|
||||
"minor": 23,
|
||||
"patch": 0
|
||||
},
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "ninja-dev",
|
||||
"displayName": "Ninja (dev)",
|
||||
"generator": "Ninja",
|
||||
"binaryDir": "${sourceDir}/build",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Debug",
|
||||
"BUILD_TESTING": "ON"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
@@ -0,0 +1,50 @@
|
||||
# Contributing to Kareer
|
||||
|
||||
## Build
|
||||
|
||||
Kareer is a KDE/Kirigami application (C++20 + QML) built with CMake and
|
||||
Extra CMake Modules. See the README for the full list of system packages
|
||||
to install, then:
|
||||
|
||||
```sh
|
||||
cmake -B build -G Ninja
|
||||
cmake --build build
|
||||
./build/bin/kareer
|
||||
```
|
||||
|
||||
Every GUI action is also available from the `kareer` CLI
|
||||
(`add|list|show|update|stage|delete|stats|stages`) — run `./build/bin/kareer
|
||||
--help` to see the subcommands.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
Tests live in `autotests/` and link the core logic directly out of `src/`
|
||||
(see `autotests/CMakeLists.txt`). CI runs the same suite on every pull
|
||||
request via `.github/workflows/test.yml`, using `flatpak-builder`'s
|
||||
`run-tests` option so the run happens inside the same KDE SDK sandbox as
|
||||
release builds.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every source file starts with `SPDX-License-Identifier: GPL-3.0-or-later`
|
||||
(in whatever comment syntax fits the file type). Files that can't carry
|
||||
an inline header — `.desktop`, `keys/*.asc` — are covered instead by
|
||||
`REUSE.toml`.
|
||||
- `src/` is intentionally flat: one class per concern, no `models/`,
|
||||
`controllers/`, or `viewmodels/` subfolders.
|
||||
- C++ backend classes are exposed to QML via `QML_ELEMENT`; QML views are
|
||||
meant to stay thin renderers over that state, not hold logic themselves.
|
||||
- `JobsDatabase` is the only class that touches the SQLite database
|
||||
directly — route all persistence changes through it.
|
||||
|
||||
## Releasing
|
||||
|
||||
`.github/workflows/build.yml` builds, signs, and publishes a Flatpak
|
||||
bundle whenever a `v*` tag is pushed. To cut a release: bump
|
||||
`project(... VERSION ...)` in `CMakeLists.txt`, add a matching `<release>`
|
||||
entry to the metainfo file, update the manifest's pinned `tag:`, then tag
|
||||
and push.
|
||||
@@ -100,19 +100,23 @@ Requires Qt 6, KDE Frameworks 6, Kirigami, Kirigami Addons and the
|
||||
CMake toolchain. On Arch/CachyOS:
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed cmake extra-cmake-modules base-devel \
|
||||
qt6-base qt6-declarative kirigami kirigami-addons \
|
||||
ki18n kcoreaddons kiconthemes kcrash kitemmodels qqc2-desktop-style
|
||||
sudo pacman -S --needed cmake ninja extra-cmake-modules base-devel \
|
||||
qt6-base qt6-declarative vulkan-headers kirigami kirigami-addons \
|
||||
ki18n kcoreaddons kiconthemes kcrash kitemmodels \
|
||||
kcolorscheme qqc2-desktop-style
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
cmake -B build -G Ninja
|
||||
cmake -B build
|
||||
cmake --build build
|
||||
./build/bin/kareer
|
||||
```
|
||||
|
||||
(If you have `ninja` installed, add `-G Ninja` to the configure step or
|
||||
use the `ninja-dev` preset: `cmake --preset ninja-dev`.)
|
||||
|
||||
Run the tests with `ctest --test-dir build`.
|
||||
|
||||
## Architecture
|
||||
@@ -128,8 +132,12 @@ Run the tests with `ctest --test-dir build`.
|
||||
- `sankeymodel.{h,cpp}` - turns stage history into laid-out Sankey
|
||||
geometry (node columns/stacking, ribbon SVG path data); QML only
|
||||
draws what this hands back.
|
||||
- `jobfieldcatalog.{h,cpp}` - the static catalog of edit-form fields and categories.
|
||||
- `jobeditmodel.{h,cpp}` - `QAbstractListModel`-backed edit-form state, built from the field catalog.
|
||||
- `clicommands.{h,cpp}` - the `add`/`list`/`show`/`update`/`stage`/
|
||||
`delete`/`stats`/`stages` subcommands.
|
||||
- `appcolorscheme.{h,cpp}` - QML-facing wrapper around `KColorSchemeManager` for the Preferences page.
|
||||
- `qml/` - Kirigami UI: `ApplicationsPage` (list + search),
|
||||
`ApplicationEditDialog` (add/edit/delete form), `DashboardPage`
|
||||
(stat cards + pipeline), `SankeyDiagram` (the renderer).
|
||||
`ApplicationEditPage` (add/edit/delete form), `DashboardPage`
|
||||
(stat cards + pipeline), `SankeyDiagram` (the renderer),
|
||||
`SettingsPage` (the Preferences page).
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
version = 1
|
||||
SPDX-PackageName = "Kareer"
|
||||
SPDX-PackageSupplier = "ToServeTheKing <[email protected]>"
|
||||
SPDX-PackageDownloadLocation = "https://github.com/toservetheking/Kareer"
|
||||
|
||||
[[annotations]]
|
||||
path = [
|
||||
"io.github.toservetheking.Kareer.desktop",
|
||||
"keys/*.asc",
|
||||
"icons/*.png",
|
||||
]
|
||||
SPDX-License-Identifier = "GPL-3.0-or-later"
|
||||
SPDX-FileCopyrightText = "ToServeTheKing <[email protected]>"
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(jobsdatabasetest
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "job.h"
|
||||
#include "jobsdatabase.h"
|
||||
#include "sankeymodel.h"
|
||||
|
||||
#include <QSqlDatabase>
|
||||
#include <QSqlQuery>
|
||||
#include <QTemporaryDir>
|
||||
#include <QtTest>
|
||||
#include <memory>
|
||||
@@ -13,6 +20,13 @@ class SankeyLayoutTest : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
// Defaults of SankeyModel::reload()/relayout(), which every test uses.
|
||||
static constexpr qreal nodeWidth = 16.0;
|
||||
static constexpr qreal rowPadding = 10.0;
|
||||
// The label margin/floor baked into the layout (sankeymodel.cpp).
|
||||
static constexpr qreal labelMargin = 8.0;
|
||||
static constexpr qreal eps = 0.01;
|
||||
|
||||
private Q_SLOTS:
|
||||
void init()
|
||||
{
|
||||
@@ -25,23 +39,13 @@ private Q_SLOTS:
|
||||
{
|
||||
JobsDatabase db;
|
||||
|
||||
auto makeJob = [&](const QString &stage) {
|
||||
Job job;
|
||||
job.company = QStringLiteral("Co");
|
||||
job.title = QStringLiteral("Title");
|
||||
QVERIFY(db.addJob(job));
|
||||
if (stage != QStringLiteral("Applied")) {
|
||||
QVERIFY(db.setStage(job.id, stage));
|
||||
}
|
||||
};
|
||||
|
||||
makeJob(QStringLiteral("Applied"));
|
||||
makeJob(QStringLiteral("Applied"));
|
||||
makeJob(QStringLiteral("Screening"));
|
||||
makeJob(QStringLiteral("Rejected"));
|
||||
makeJob(db, QStringLiteral("Applied"));
|
||||
makeJob(db, QStringLiteral("Applied"));
|
||||
makeJob(db, QStringLiteral("Screening"));
|
||||
makeJob(db, QStringLiteral("Rejected"));
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(600, 400);
|
||||
model.reload(600, 400);
|
||||
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
@@ -80,13 +84,263 @@ private Q_SLOTS:
|
||||
QVERIFY(db.isOpen());
|
||||
|
||||
SankeyModel model;
|
||||
model.relayout(400, 300);
|
||||
model.reload(400, 300);
|
||||
QVERIFY(model.isEmpty());
|
||||
QVERIFY(model.nodes().isEmpty());
|
||||
QVERIFY(model.links().isEmpty());
|
||||
}
|
||||
|
||||
void geometryFitsViewport()
|
||||
{
|
||||
JobsDatabase db;
|
||||
seedDensePipeline(db);
|
||||
|
||||
SankeyModel model;
|
||||
for (const QSizeF size : {QSizeF(600, 300), QSizeF(600, 40)}) {
|
||||
model.reload(size.width(), size.height());
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
QHash<qreal, qreal> columnHeights;
|
||||
QHash<qreal, int> columnCounts;
|
||||
for (const QVariant &v : model.nodes()) {
|
||||
const QVariantMap m = v.toMap();
|
||||
const qreal x = m.value(QStringLiteral("x")).toReal();
|
||||
const qreal y = m.value(QStringLiteral("y")).toReal();
|
||||
const qreal w = m.value(QStringLiteral("width")).toReal();
|
||||
const qreal h = m.value(QStringLiteral("height")).toReal();
|
||||
QVERIFY(x >= -eps);
|
||||
QVERIFY(x + w <= size.width() + eps);
|
||||
QVERIFY(y >= -eps);
|
||||
QVERIFY(y + h <= size.height() + eps);
|
||||
columnHeights[x] += h;
|
||||
columnCounts[x] += 1;
|
||||
|
||||
// The label rect must lie inside the viewport too.
|
||||
const qreal labelWidth = m.value(QStringLiteral("labelWidth")).toReal();
|
||||
if (m.value(QStringLiteral("labelOnRight")).toBool()) {
|
||||
QVERIFY(x + w + labelMargin + labelWidth <= size.width() + eps);
|
||||
} else {
|
||||
QVERIFY(x - labelMargin - labelWidth >= -eps);
|
||||
}
|
||||
}
|
||||
for (auto it = columnHeights.constBegin(); it != columnHeights.constEnd(); ++it) {
|
||||
const qreal gaps = rowPadding * (columnCounts.value(it.key()) - 1);
|
||||
QVERIFY(it.value() + gaps <= size.height() + eps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ribbonsStayInsideNodes()
|
||||
{
|
||||
JobsDatabase db;
|
||||
seedDensePipeline(db);
|
||||
|
||||
SankeyModel model;
|
||||
model.reload(600, 300);
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
QHash<QString, QVariantMap> nodeByStage;
|
||||
for (const QVariant &v : model.nodes()) {
|
||||
const QVariantMap m = v.toMap();
|
||||
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
|
||||
}
|
||||
|
||||
QHash<QString, qreal> outboundTotal;
|
||||
QHash<QString, qreal> inboundTotal;
|
||||
for (const QVariant &v : model.links()) {
|
||||
const QVariantMap m = v.toMap();
|
||||
const qreal thickness = m.value(QStringLiteral("thickness")).toReal();
|
||||
QVERIFY(thickness > 0);
|
||||
|
||||
const QVariantMap fromNode = nodeByStage.value(m.value(QStringLiteral("fromStage")).toString());
|
||||
const QVariantMap toNode = nodeByStage.value(m.value(QStringLiteral("toStage")).toString());
|
||||
const qreal sourceY = m.value(QStringLiteral("sourceY")).toReal();
|
||||
const qreal targetY = m.value(QStringLiteral("targetY")).toReal();
|
||||
|
||||
QVERIFY(sourceY >= fromNode.value(QStringLiteral("y")).toReal() - eps);
|
||||
QVERIFY(sourceY + thickness <= fromNode.value(QStringLiteral("y")).toReal() + fromNode.value(QStringLiteral("height")).toReal() + eps);
|
||||
QVERIFY(targetY >= toNode.value(QStringLiteral("y")).toReal() - eps);
|
||||
QVERIFY(targetY + thickness <= toNode.value(QStringLiteral("y")).toReal() + toNode.value(QStringLiteral("height")).toReal() + eps);
|
||||
|
||||
outboundTotal[m.value(QStringLiteral("fromStage")).toString()] += thickness;
|
||||
inboundTotal[m.value(QStringLiteral("toStage")).toString()] += thickness;
|
||||
}
|
||||
for (auto it = outboundTotal.constBegin(); it != outboundTotal.constEnd(); ++it) {
|
||||
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
|
||||
}
|
||||
for (auto it = inboundTotal.constBegin(); it != inboundTotal.constEnd(); ++it) {
|
||||
QVERIFY(it.value() <= nodeByStage.value(it.key()).value(QStringLiteral("height")).toReal() + eps);
|
||||
}
|
||||
}
|
||||
|
||||
void ribbonSlotsFollowEndpointHeights()
|
||||
{
|
||||
JobsDatabase db;
|
||||
seedDensePipeline(db);
|
||||
|
||||
SankeyModel model;
|
||||
model.reload(600, 300);
|
||||
QVERIFY(!model.isEmpty());
|
||||
|
||||
QHash<QString, QVariantMap> nodeByStage;
|
||||
for (const QVariant &v : model.nodes()) {
|
||||
const QVariantMap m = v.toMap();
|
||||
nodeByStage.insert(m.value(QStringLiteral("stage")).toString(), m);
|
||||
}
|
||||
const auto center = [&](const QString &stage) {
|
||||
const QVariantMap n = nodeByStage.value(stage);
|
||||
return n.value(QStringLiteral("y")).toReal() + n.value(QStringLiteral("height")).toReal() / 2.0;
|
||||
};
|
||||
|
||||
// Outgoing ribbons must stack top-to-bottom in order of their
|
||||
// target's height, incoming ones by their source's height, or the
|
||||
// ribbons twist over each other right at the node.
|
||||
QHash<QString, QList<QPair<qreal, qreal>>> outgoing; // sourceY -> target center
|
||||
QHash<QString, QList<QPair<qreal, qreal>>> incoming; // targetY -> source center
|
||||
for (const QVariant &v : model.links()) {
|
||||
const QVariantMap m = v.toMap();
|
||||
outgoing[m.value(QStringLiteral("fromStage")).toString()].append(
|
||||
{m.value(QStringLiteral("sourceY")).toReal(), center(m.value(QStringLiteral("toStage")).toString())});
|
||||
incoming[m.value(QStringLiteral("toStage")).toString()].append(
|
||||
{m.value(QStringLiteral("targetY")).toReal(), center(m.value(QStringLiteral("fromStage")).toString())});
|
||||
}
|
||||
const auto verifySorted = [](QList<QPair<qreal, qreal>> slots) {
|
||||
std::sort(slots.begin(), slots.end());
|
||||
for (int i = 1; i < slots.size(); ++i) {
|
||||
QVERIFY(slots.at(i).second >= slots.at(i - 1).second - 0.01);
|
||||
}
|
||||
};
|
||||
for (const auto &slots : std::as_const(outgoing)) {
|
||||
verifySorted(slots);
|
||||
}
|
||||
for (const auto &slots : std::as_const(incoming)) {
|
||||
verifySorted(slots);
|
||||
}
|
||||
}
|
||||
|
||||
void sparseColumnsFillWidth()
|
||||
{
|
||||
JobsDatabase db;
|
||||
makeJob(db, QStringLiteral("Applied"));
|
||||
makeJob(db, QStringLiteral("Applied"));
|
||||
makeJob(db, QStringLiteral("Rejected"));
|
||||
|
||||
SankeyModel model;
|
||||
model.reload(600, 300);
|
||||
|
||||
// Only Start, Applied and Rejected are present: their columns should
|
||||
// spread evenly over the full width, not sit at canonical positions.
|
||||
QList<qreal> xs;
|
||||
for (const QVariant &v : model.nodes()) {
|
||||
const qreal x = v.toMap().value(QStringLiteral("x")).toReal();
|
||||
if (!xs.contains(x)) {
|
||||
xs.append(x);
|
||||
}
|
||||
}
|
||||
std::sort(xs.begin(), xs.end());
|
||||
QCOMPARE(xs.size(), 3);
|
||||
QVERIFY(qAbs(xs.at(0)) < eps);
|
||||
QVERIFY(qAbs(xs.at(1) - (600 - nodeWidth) / 2.0) < eps);
|
||||
QVERIFY(qAbs(xs.at(2) - (600 - nodeWidth)) < eps);
|
||||
}
|
||||
|
||||
void caseVariantStageIsCanonicalized()
|
||||
{
|
||||
JobsDatabase db;
|
||||
|
||||
Job job;
|
||||
job.company = QStringLiteral("Co");
|
||||
job.title = QStringLiteral("Title");
|
||||
QVERIFY(db.addJob(job));
|
||||
QVERIFY(db.setStage(job.id, QStringLiteral("rejected")));
|
||||
QCOMPARE(db.jobById(job.id)->stage, QStringLiteral("Rejected"));
|
||||
|
||||
SankeyModel model;
|
||||
model.reload(600, 300);
|
||||
QStringList stages;
|
||||
for (const QVariant &v : model.nodes()) {
|
||||
stages.append(v.toMap().value(QStringLiteral("stage")).toString());
|
||||
}
|
||||
QVERIFY(stages.contains(QStringLiteral("Rejected")));
|
||||
QVERIFY(!stages.contains(QStringLiteral("rejected")));
|
||||
|
||||
// Pre-canonicalization rows already in the database are repaired the
|
||||
// next time it is opened (migrate() normalization).
|
||||
{
|
||||
QSqlDatabase raw = QSqlDatabase::addDatabase(QStringLiteral("QSQLITE"), QStringLiteral("fixup"));
|
||||
raw.setDatabaseName(m_dir->path() + QStringLiteral("/test.sqlite"));
|
||||
QVERIFY(raw.open());
|
||||
QSqlQuery q(raw);
|
||||
QVERIFY(q.exec(QStringLiteral("UPDATE jobs SET stage = 'ghosted'")));
|
||||
QVERIFY(q.exec(QStringLiteral("UPDATE stage_history SET to_stage = 'ghosted' WHERE to_stage = 'Rejected'")));
|
||||
raw.close();
|
||||
}
|
||||
QSqlDatabase::removeDatabase(QStringLiteral("fixup"));
|
||||
|
||||
JobsDatabase reopened;
|
||||
QVERIFY(reopened.isOpen());
|
||||
QCOMPARE(reopened.jobById(job.id)->stage, QStringLiteral("Ghosted"));
|
||||
const auto transitions = reopened.stageTransitions();
|
||||
for (const StageTransition &t : transitions) {
|
||||
QVERIFY(t.toStage != QStringLiteral("ghosted"));
|
||||
}
|
||||
}
|
||||
|
||||
void degenerateSizesDoNotCrash()
|
||||
{
|
||||
JobsDatabase db;
|
||||
seedDensePipeline(db);
|
||||
|
||||
SankeyModel model;
|
||||
model.reload(0, 0);
|
||||
QVERIFY(model.isEmpty());
|
||||
model.relayout(-5, 100);
|
||||
QVERIFY(model.isEmpty());
|
||||
model.relayout(5, 5);
|
||||
model.relayout(2000, 2);
|
||||
model.relayout(600, 300);
|
||||
QVERIFY(!model.isEmpty());
|
||||
}
|
||||
|
||||
private:
|
||||
static void makeJob(JobsDatabase &db, const QString &stage)
|
||||
{
|
||||
Job job;
|
||||
job.company = QStringLiteral("Co");
|
||||
job.title = QStringLiteral("Title");
|
||||
QVERIFY(db.addJob(job));
|
||||
if (stage != QStringLiteral("Applied")) {
|
||||
QVERIFY(db.setStage(job.id, stage));
|
||||
}
|
||||
}
|
||||
|
||||
static void walkJob(JobsDatabase &db, const QStringList &stages)
|
||||
{
|
||||
Job job;
|
||||
job.company = QStringLiteral("Co");
|
||||
job.title = QStringLiteral("Title");
|
||||
QVERIFY(db.addJob(job));
|
||||
for (const QString &stage : stages) {
|
||||
QVERIFY(db.setStage(job.id, stage));
|
||||
}
|
||||
}
|
||||
|
||||
/// Populates every column, including all four terminal outcomes sharing
|
||||
/// the last one.
|
||||
static void seedDensePipeline(JobsDatabase &db)
|
||||
{
|
||||
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Offer"), QStringLiteral("Accepted")});
|
||||
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Rejected")});
|
||||
walkJob(db, {QStringLiteral("Ghosted")});
|
||||
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Rejected")});
|
||||
walkJob(db, {QStringLiteral("Withdrawn")});
|
||||
walkJob(db, {QStringLiteral("Screening"), QStringLiteral("Interview"), QStringLiteral("Onsite"), QStringLiteral("Rejected")});
|
||||
walkJob(db, {});
|
||||
walkJob(db, {});
|
||||
walkJob(db, {});
|
||||
walkJob(db, {});
|
||||
}
|
||||
|
||||
std::unique_ptr<QTemporaryDir> m_dir;
|
||||
};
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 572 B |
|
After Width: | Height: | Size: 825 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,16 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
ecm_install_icons(ICONS
|
||||
sc-apps-kareer.svg
|
||||
128-apps-kareer.png
|
||||
64-apps-kareer.png
|
||||
48-apps-kareer.png
|
||||
44-apps-kareer.png
|
||||
32-apps-kareer.png
|
||||
22-apps-kareer.png
|
||||
16-apps-kareer.png
|
||||
DESTINATION ${KDE_INSTALL_ICONDIR}
|
||||
THEME hicolor
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]> -->
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<svg width="128" height="128" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@@ -64,6 +64,36 @@
|
||||
<content_rating type="oars-1.1"/>
|
||||
|
||||
<releases>
|
||||
<release version="0.1.3" date="2026-08-31">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Redesign the Sankey pipeline layout: top-aligned funnel, columns spread across the full width, labels always visible</li>
|
||||
<li>Order ribbons at each stage to stop them braiding over each other</li>
|
||||
<li>Canonicalize stage names written via the CLI (e.g. "rejected" now maps to "Rejected") and repair existing databases</li>
|
||||
<li>Debounce window-resize relayouts and stop re-reading the database on resize</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.2" date="2026-07-03">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Rework the add/edit form onto a generic, catalog-driven field model</li>
|
||||
<li>Add a Preferences page with a color-scheme switcher</li>
|
||||
<li>Add an About page</li>
|
||||
<li>Ship a full hicolor icon set instead of a single scalable SVG</li>
|
||||
<li>Match KDE System Settings' Audio page layout for the edit form</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.1" date="2026-07-03">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Run the test suite in CI on every pull request</li>
|
||||
<li>Add REUSE license-compliance metadata</li>
|
||||
<li>Add a CONTRIBUTING guide</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.1.0" date="2026-07-03">
|
||||
<description>
|
||||
<p>Initial release.</p>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
id: io.github.toservetheking.Kareer
|
||||
runtime: org.kde.Platform
|
||||
@@ -25,13 +27,14 @@ modules:
|
||||
buildsystem: cmake-ninja
|
||||
config-opts:
|
||||
- -DCMAKE_BUILD_TYPE=Release
|
||||
run-tests: true
|
||||
sources:
|
||||
# For Flathub / release builds, pin to a tag AND commit:
|
||||
- type: git
|
||||
url: https://github.com/toservetheking/Kareer.git
|
||||
tag: v0.1.0
|
||||
# commit: <fill in the exact commit SHA the tag points at>
|
||||
# For local testing before the repo is pushed:
|
||||
- type: dir
|
||||
path: .
|
||||
#
|
||||
# For local testing before the repo is pushed, replace the source above with:
|
||||
# - type: dir
|
||||
# path: .
|
||||
# For Flathub / release builds, pin to a tag AND commit instead:
|
||||
# - type: git
|
||||
# url: https://github.com/toservetheking/Kareer.git
|
||||
# tag: v0.1.1
|
||||
# commit: <fill in the exact commit SHA the tag points at>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
add_executable(kareer
|
||||
main.cpp
|
||||
jobstage.cpp
|
||||
jobsdatabase.cpp
|
||||
jobfieldcatalog.cpp
|
||||
clicommands.cpp
|
||||
)
|
||||
|
||||
@@ -14,9 +17,10 @@ qt_add_qml_module(kareer
|
||||
QML_FILES
|
||||
qml/Main.qml
|
||||
qml/ApplicationsPage.qml
|
||||
qml/ApplicationEditDialog.qml
|
||||
qml/ApplicationEditPage.qml
|
||||
qml/DashboardPage.qml
|
||||
qml/SankeyDiagram.qml
|
||||
qml/SettingsPage.qml
|
||||
SOURCES
|
||||
jobsmodel.cpp
|
||||
jobsmodel.h
|
||||
@@ -24,6 +28,10 @@ qt_add_qml_module(kareer
|
||||
statsmodel.h
|
||||
sankeymodel.cpp
|
||||
sankeymodel.h
|
||||
jobeditmodel.cpp
|
||||
jobeditmodel.h
|
||||
appcolorscheme.cpp
|
||||
appcolorscheme.h
|
||||
)
|
||||
|
||||
target_include_directories(kareer PRIVATE ${CMAKE_BINARY_DIR})
|
||||
@@ -41,6 +49,8 @@ target_link_libraries(kareer PRIVATE
|
||||
KF6::CoreAddons
|
||||
KF6::IconThemes
|
||||
KF6::Crash
|
||||
KF6::ColorScheme
|
||||
KF6::Kirigami
|
||||
)
|
||||
|
||||
install(TARGETS kareer ${KDE_INSTALL_TARGETS_DEFAULT_ARGS})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "appcolorscheme.h"
|
||||
|
||||
#include <KColorSchemeManager>
|
||||
|
||||
AppColorScheme::AppColorScheme(QObject *parent)
|
||||
: QObject(parent)
|
||||
, mSchemes(KColorSchemeManager::instance())
|
||||
{
|
||||
}
|
||||
|
||||
QAbstractItemModel *AppColorScheme::colorSchemesModel()
|
||||
{
|
||||
return mSchemes->model();
|
||||
}
|
||||
|
||||
QString AppColorScheme::activeColorSchemeName() const
|
||||
{
|
||||
return mSchemes->activeSchemeName();
|
||||
}
|
||||
|
||||
void AppColorScheme::setActiveColorSchemeName(const QString &name)
|
||||
{
|
||||
if (name == activeColorSchemeName()) {
|
||||
return;
|
||||
}
|
||||
mSchemes->activateScheme(mSchemes->indexForScheme(name));
|
||||
Q_EMIT activeColorSchemeNameChanged();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QAbstractItemModel>
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
|
||||
class KColorSchemeManager;
|
||||
|
||||
/**
|
||||
* Thin QML-facing wrapper around KColorSchemeManager: exposes the list of
|
||||
* installed color schemes and the currently active one. KColorSchemeManager
|
||||
* autosaves the active scheme itself, so there is nothing else to persist.
|
||||
*/
|
||||
class AppColorScheme : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
QML_SINGLETON
|
||||
Q_PROPERTY(QAbstractItemModel *colorSchemesModel READ colorSchemesModel CONSTANT)
|
||||
Q_PROPERTY(QString activeColorSchemeName READ activeColorSchemeName WRITE setActiveColorSchemeName NOTIFY activeColorSchemeNameChanged)
|
||||
|
||||
public:
|
||||
explicit AppColorScheme(QObject *parent = nullptr);
|
||||
|
||||
QAbstractItemModel *colorSchemesModel();
|
||||
QString activeColorSchemeName() const;
|
||||
void setActiveColorSchemeName(const QString &name);
|
||||
|
||||
Q_SIGNALS:
|
||||
void activeColorSchemeNameChanged();
|
||||
|
||||
private:
|
||||
KColorSchemeManager *mSchemes;
|
||||
};
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "clicommands.h"
|
||||
|
||||
#include "job.h"
|
||||
@@ -438,7 +443,7 @@ int runStage(const QString &program, const QStringList &args)
|
||||
if (parser.isSet(u"json"_s)) {
|
||||
printJson(jobToJson(*job));
|
||||
} else {
|
||||
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(stage) << Qt::endl;
|
||||
QTextStream(stdout) << u"Application #%1 moved to %2"_s.arg(id).arg(job->stage) << Qt::endl;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QDate>
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobeditmodel.h"
|
||||
|
||||
#include <QDate>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
using JobFieldCatalog::ComboRow;
|
||||
using JobFieldCatalog::DateRow;
|
||||
using JobFieldCatalog::Field;
|
||||
using JobFieldCatalog::SpinBoxRow;
|
||||
using JobFieldCatalog::TextAreaRow;
|
||||
using JobFieldCatalog::TextRow;
|
||||
|
||||
namespace
|
||||
{
|
||||
/// The three salary fields share the "0 shown in the UI means unset, -1 stored" convention.
|
||||
bool isSalaryField(const QString &id)
|
||||
{
|
||||
return id == QLatin1String("salaryMin") || id == QLatin1String("salaryMax") || id == QLatin1String("salaryExpectation");
|
||||
}
|
||||
}
|
||||
|
||||
JobEditModel::JobEditModel(QObject *parent)
|
||||
: QAbstractListModel(parent)
|
||||
, m_fields(JobFieldCatalog::fields())
|
||||
{
|
||||
resetValues();
|
||||
}
|
||||
|
||||
int JobEditModel::rowCount(const QModelIndex &parent) const
|
||||
{
|
||||
if (parent.isValid()) {
|
||||
return 0;
|
||||
}
|
||||
return m_fields.size();
|
||||
}
|
||||
|
||||
QVariant JobEditModel::data(const QModelIndex &index, int role) const
|
||||
{
|
||||
if (!index.isValid() || index.row() < 0 || index.row() >= m_fields.size()) {
|
||||
return {};
|
||||
}
|
||||
const Field &field = m_fields.at(index.row());
|
||||
switch (role) {
|
||||
case FieldIdRole:
|
||||
return field.id;
|
||||
case CategoryIdRole:
|
||||
return field.categoryId;
|
||||
case LabelRole:
|
||||
return field.label;
|
||||
case RowTypeRole:
|
||||
return static_cast<int>(field.rowType);
|
||||
case ValueRole:
|
||||
return m_values.value(field.id);
|
||||
case ComboOptionsRole:
|
||||
return field.comboOptions;
|
||||
case PlaceholderRole:
|
||||
return field.placeholder;
|
||||
case SpinMinRole:
|
||||
return field.spinMin;
|
||||
case SpinMaxRole:
|
||||
return field.spinMax;
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QHash<int, QByteArray> JobEditModel::roleNames() const
|
||||
{
|
||||
return {
|
||||
{FieldIdRole, "fieldId"},
|
||||
{CategoryIdRole, "categoryId"},
|
||||
{LabelRole, "label"},
|
||||
{RowTypeRole, "rowType"},
|
||||
{ValueRole, "value"},
|
||||
{ComboOptionsRole, "comboOptions"},
|
||||
{PlaceholderRole, "placeholder"},
|
||||
{SpinMinRole, "spinMin"},
|
||||
{SpinMaxRole, "spinMax"},
|
||||
};
|
||||
}
|
||||
|
||||
JobsModel *JobEditModel::jobsModel() const
|
||||
{
|
||||
return m_jobsModel;
|
||||
}
|
||||
|
||||
void JobEditModel::setJobsModel(JobsModel *model)
|
||||
{
|
||||
if (m_jobsModel == model) {
|
||||
return;
|
||||
}
|
||||
m_jobsModel = model;
|
||||
Q_EMIT jobsModelChanged();
|
||||
}
|
||||
|
||||
int JobEditModel::editingJobId() const
|
||||
{
|
||||
return m_editingJobId;
|
||||
}
|
||||
|
||||
void JobEditModel::setEditingJobId(int id)
|
||||
{
|
||||
if (m_editingJobId == id) {
|
||||
return;
|
||||
}
|
||||
m_editingJobId = id;
|
||||
resetValues();
|
||||
Q_EMIT editingJobIdChanged();
|
||||
}
|
||||
|
||||
void JobEditModel::resetValues()
|
||||
{
|
||||
m_values.clear();
|
||||
|
||||
if (m_editingJobId < 0 || !m_jobsModel) {
|
||||
m_values[u"currency"_s] = u"USD"_s;
|
||||
m_values[u"stage"_s] = u"Applied"_s;
|
||||
m_values[u"dateApplied"_s] = QDate::currentDate();
|
||||
m_values[u"remoteType"_s] = u"Unspecified"_s;
|
||||
m_values[u"salaryMin"_s] = 0;
|
||||
m_values[u"salaryMax"_s] = 0;
|
||||
m_values[u"salaryExpectation"_s] = 0;
|
||||
} else {
|
||||
const QVariantMap data = m_jobsModel->jobData(m_editingJobId);
|
||||
for (const Field &field : m_fields) {
|
||||
QVariant value = data.value(field.id);
|
||||
if (field.id == u"remoteType"_s && value.toString().isEmpty()) {
|
||||
value = u"Unspecified"_s;
|
||||
}
|
||||
if (isSalaryField(field.id) && value.toInt() < 0) {
|
||||
value = 0;
|
||||
}
|
||||
m_values[field.id] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (rowCount() > 0) {
|
||||
Q_EMIT dataChanged(index(0), index(rowCount() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
QVariantList JobEditModel::categories() const
|
||||
{
|
||||
QVariantList result;
|
||||
for (const JobFieldCatalog::Category &category : JobFieldCatalog::categories()) {
|
||||
result.append(QVariantMap{{u"id"_s, category.id}, {u"title"_s, category.title}});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
QString JobEditModel::lastError() const
|
||||
{
|
||||
return m_lastError;
|
||||
}
|
||||
|
||||
void JobEditModel::setValue(int row, const QVariant &value)
|
||||
{
|
||||
if (row < 0 || row >= m_fields.size()) {
|
||||
return;
|
||||
}
|
||||
m_values[m_fields.at(row).id] = value;
|
||||
Q_EMIT dataChanged(index(row), index(row), {ValueRole});
|
||||
}
|
||||
|
||||
bool JobEditModel::save()
|
||||
{
|
||||
if (!m_jobsModel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QVariantMap fields;
|
||||
for (auto it = m_values.constBegin(); it != m_values.constEnd(); ++it) {
|
||||
fields.insert(it.key(), it.value());
|
||||
}
|
||||
if (fields.value(u"remoteType"_s).toString() == u"Unspecified"_s) {
|
||||
fields[u"remoteType"_s] = QString();
|
||||
}
|
||||
for (const QString &id : {u"salaryMin"_s, u"salaryMax"_s, u"salaryExpectation"_s}) {
|
||||
if (fields.value(id).toInt() <= 0) {
|
||||
fields[id] = -1;
|
||||
}
|
||||
}
|
||||
|
||||
const bool ok = m_editingJobId < 0 ? m_jobsModel->addJob(fields) : m_jobsModel->updateJob(m_editingJobId, fields);
|
||||
if (!ok) {
|
||||
m_lastError = m_jobsModel->lastError();
|
||||
Q_EMIT lastErrorChanged();
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool JobEditModel::deleteJob()
|
||||
{
|
||||
if (!m_jobsModel || m_editingJobId < 0) {
|
||||
return false;
|
||||
}
|
||||
return m_jobsModel->removeJob(m_editingJobId);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "jobfieldcatalog.h"
|
||||
#include "jobsmodel.h"
|
||||
|
||||
#include <QAbstractListModel>
|
||||
#include <QHash>
|
||||
#include <QQmlEngine>
|
||||
#include <QVariant>
|
||||
|
||||
/**
|
||||
* Drives the add/edit form the way FlatKontrol's PermissionsController drives
|
||||
* PermissionsPage: a generic row model (one row per JobFieldCatalog::Field)
|
||||
* that QML renders via Repeater + DelegateChooser on rowType, instead of
|
||||
* each field being hand-written in QML.
|
||||
*/
|
||||
class JobEditModel : public QAbstractListModel
|
||||
{
|
||||
Q_OBJECT
|
||||
QML_ELEMENT
|
||||
|
||||
Q_PROPERTY(JobsModel *jobsModel READ jobsModel WRITE setJobsModel NOTIFY jobsModelChanged)
|
||||
Q_PROPERTY(int editingJobId READ editingJobId WRITE setEditingJobId NOTIFY editingJobIdChanged)
|
||||
Q_PROPERTY(QVariantList categories READ categories CONSTANT)
|
||||
Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
|
||||
|
||||
public:
|
||||
enum Roles {
|
||||
FieldIdRole = Qt::UserRole + 1,
|
||||
CategoryIdRole,
|
||||
LabelRole,
|
||||
RowTypeRole,
|
||||
ValueRole,
|
||||
ComboOptionsRole,
|
||||
PlaceholderRole,
|
||||
SpinMinRole,
|
||||
SpinMaxRole,
|
||||
};
|
||||
Q_ENUM(Roles)
|
||||
|
||||
explicit JobEditModel(QObject *parent = nullptr);
|
||||
|
||||
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
QHash<int, QByteArray> roleNames() const override;
|
||||
|
||||
JobsModel *jobsModel() const;
|
||||
void setJobsModel(JobsModel *model);
|
||||
|
||||
int editingJobId() const;
|
||||
void setEditingJobId(int id);
|
||||
|
||||
QVariantList categories() const;
|
||||
QString lastError() const;
|
||||
|
||||
/// Updates the value for the field at this row (called from the QML delegate).
|
||||
Q_INVOKABLE void setValue(int row, const QVariant &value);
|
||||
|
||||
/// Persists the current values via jobsModel; true on success.
|
||||
Q_INVOKABLE bool save();
|
||||
|
||||
Q_INVOKABLE bool deleteJob();
|
||||
|
||||
Q_SIGNALS:
|
||||
void jobsModelChanged();
|
||||
void editingJobIdChanged();
|
||||
void lastErrorChanged();
|
||||
|
||||
private:
|
||||
void resetValues();
|
||||
|
||||
JobsModel *m_jobsModel = nullptr;
|
||||
int m_editingJobId = -1;
|
||||
QHash<QString, QVariant> m_values;
|
||||
QString m_lastError;
|
||||
QList<JobFieldCatalog::Field> m_fields;
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobfieldcatalog.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
namespace JobFieldCatalog
|
||||
{
|
||||
|
||||
QList<Category> categories()
|
||||
{
|
||||
return {
|
||||
{u"company"_s, QStringLiteral("Company")},
|
||||
{u"pipeline"_s, QStringLiteral("Pipeline")},
|
||||
{u"salary"_s, QStringLiteral("Salary")},
|
||||
{u"details"_s, QStringLiteral("Additional Details")},
|
||||
};
|
||||
}
|
||||
|
||||
QList<Field> fields()
|
||||
{
|
||||
return {
|
||||
{u"company"_s, u"company"_s, QStringLiteral("Company:"), TextRow, {}, {}, 0, 0},
|
||||
{u"title"_s, u"company"_s, QStringLiteral("Job Title:"), TextRow, {}, {}, 0, 0},
|
||||
{u"location"_s, u"company"_s, QStringLiteral("Location:"), TextRow, {}, {}, 0, 0},
|
||||
{u"remoteType"_s, u"company"_s, QStringLiteral("Remote Type:"), ComboRow, {QStringLiteral("Unspecified"), QStringLiteral("Onsite"), QStringLiteral("Hybrid"), QStringLiteral("Remote")}, {}, 0, 0},
|
||||
|
||||
{u"stage"_s, u"pipeline"_s, QStringLiteral("Stage:"), ComboRow, JobStage::canonicalStages(), {}, 0, 0},
|
||||
{u"dateApplied"_s, u"pipeline"_s, QStringLiteral("Date Applied:"), DateRow, {}, {}, 0, 0},
|
||||
|
||||
{u"salaryMin"_s, u"salary"_s, QStringLiteral("Range Minimum:"), SpinBoxRow, {}, {}, 0, 5000000},
|
||||
{u"salaryMax"_s, u"salary"_s, QStringLiteral("Range Maximum:"), SpinBoxRow, {}, {}, 0, 5000000},
|
||||
{u"salaryExpectation"_s, u"salary"_s, QStringLiteral("Your Expectation:"), SpinBoxRow, {}, {}, 0, 5000000},
|
||||
{u"currency"_s, u"salary"_s, QStringLiteral("Currency:"), TextRow, {}, {}, 0, 0},
|
||||
|
||||
{u"source"_s, u"details"_s, QStringLiteral("Source:"), TextRow, {}, QStringLiteral("Referral, LinkedIn, company site..."), 0, 0},
|
||||
{u"url"_s, u"details"_s, QStringLiteral("Job Posting URL:"), TextRow, {}, {}, 0, 0},
|
||||
{u"contact"_s, u"details"_s, QStringLiteral("Contact:"), TextRow, {}, {}, 0, 0},
|
||||
{u"notes"_s, u"details"_s, QStringLiteral("Notes:"), TextAreaRow, {}, {}, 0, 0},
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QList>
|
||||
|
||||
/**
|
||||
* The static structure of the job edit form: which categories exist, and
|
||||
* which fields belong to each, in display order. Mirrors FlatKontrol's
|
||||
* PermissionCatalog - JobEditModel supplies the per-job values, this
|
||||
* supplies the shape.
|
||||
*/
|
||||
namespace JobFieldCatalog
|
||||
{
|
||||
enum RowType {
|
||||
TextRow = 0,
|
||||
ComboRow,
|
||||
SpinBoxRow,
|
||||
DateRow,
|
||||
TextAreaRow,
|
||||
};
|
||||
|
||||
struct Category {
|
||||
QString id;
|
||||
QString title;
|
||||
};
|
||||
|
||||
struct Field {
|
||||
QString id; ///< Matches a Job/JobsModel field key (see JobsModel::mapFromJob).
|
||||
QString categoryId;
|
||||
QString label;
|
||||
RowType rowType;
|
||||
QStringList comboOptions; ///< Only meaningful for ComboRow.
|
||||
QString placeholder; ///< Only meaningful for TextRow.
|
||||
int spinMin = 0; ///< Only meaningful for SpinBoxRow.
|
||||
int spinMax = 0;
|
||||
};
|
||||
|
||||
QList<Category> categories();
|
||||
QList<Field> fields();
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
@@ -127,6 +132,26 @@ bool JobsDatabase::migrate()
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rows written before stages were canonicalized on write (e.g.
|
||||
// "rejected" via the CLI) would otherwise show up as separate Sankey
|
||||
// nodes with default column/color. lower() is ASCII-only, which is fine:
|
||||
// the stage vocabulary is ASCII.
|
||||
for (const QString &stage : JobStage::canonicalStages()) {
|
||||
for (const QString &statement : {
|
||||
u"UPDATE jobs SET stage = :s WHERE stage != :s AND lower(stage) = lower(:s)"_s,
|
||||
u"UPDATE stage_history SET to_stage = :s WHERE to_stage != :s AND lower(to_stage) = lower(:s)"_s,
|
||||
u"UPDATE stage_history SET from_stage = :s WHERE from_stage != :s AND lower(from_stage) = lower(:s)"_s,
|
||||
}) {
|
||||
QSqlQuery normalize(db);
|
||||
normalize.prepare(statement);
|
||||
normalize.bindValue(u":s"_s, stage);
|
||||
if (!normalize.exec()) {
|
||||
m_lastError = normalize.lastError().text();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -197,6 +222,7 @@ bool JobsDatabase::addJob(Job &job)
|
||||
m_lastError = u"Unknown stage '%1'"_s.arg(job.stage);
|
||||
return false;
|
||||
}
|
||||
job.stage = JobStage::canonical(job.stage);
|
||||
|
||||
QSqlDatabase db = QSqlDatabase::database(m_connectionName);
|
||||
const QDateTime now = QDateTime::currentDateTimeUtc();
|
||||
@@ -294,13 +320,14 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
|
||||
m_lastError = u"Unknown stage '%1'"_s.arg(newStage);
|
||||
return false;
|
||||
}
|
||||
const QString stage = JobStage::canonical(newStage);
|
||||
|
||||
const auto current = jobById(id);
|
||||
if (!current) {
|
||||
m_lastError = u"No job with id %1"_s.arg(id);
|
||||
return false;
|
||||
}
|
||||
if (current->stage.compare(newStage, Qt::CaseInsensitive) == 0) {
|
||||
if (current->stage.compare(stage, Qt::CaseInsensitive) == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -309,7 +336,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
|
||||
|
||||
QSqlQuery query(db);
|
||||
query.prepare(u"UPDATE jobs SET stage = :stage, updated_at = :updated_at WHERE id = :id"_s);
|
||||
query.bindValue(u":stage"_s, newStage);
|
||||
query.bindValue(u":stage"_s, stage);
|
||||
query.bindValue(u":updated_at"_s, now.toString(Qt::ISODate));
|
||||
query.bindValue(u":id"_s, id);
|
||||
if (!query.exec()) {
|
||||
@@ -321,7 +348,7 @@ bool JobsDatabase::setStage(int id, const QString &newStage)
|
||||
history.prepare(u"INSERT INTO stage_history (job_id, from_stage, to_stage, changed_at) VALUES (:job_id, :from_stage, :to_stage, :changed_at)"_s);
|
||||
history.bindValue(u":job_id"_s, id);
|
||||
history.bindValue(u":from_stage"_s, current->stage);
|
||||
history.bindValue(u":to_stage"_s, newStage);
|
||||
history.bindValue(u":to_stage"_s, stage);
|
||||
history.bindValue(u":changed_at"_s, now.toString(Qt::ISODate));
|
||||
if (!history.exec()) {
|
||||
m_lastError = history.lastError().text();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobsmodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "job.h"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "jobstage.h"
|
||||
|
||||
#include <QHash>
|
||||
@@ -27,6 +32,19 @@ bool isValid(const QString &stage)
|
||||
return canonicalStages().contains(stage, Qt::CaseInsensitive);
|
||||
}
|
||||
|
||||
QString canonical(const QString &stage)
|
||||
{
|
||||
if (stage.compare(QLatin1String(Start), Qt::CaseInsensitive) == 0) {
|
||||
return QString::fromLatin1(Start);
|
||||
}
|
||||
for (const QString &candidate : canonicalStages()) {
|
||||
if (stage.compare(candidate, Qt::CaseInsensitive) == 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return stage;
|
||||
}
|
||||
|
||||
int column(const QString &stage)
|
||||
{
|
||||
static const QHash<QString, int> columns{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QColor>
|
||||
@@ -20,6 +25,11 @@ QStringList canonicalStages();
|
||||
|
||||
bool isValid(const QString &stage);
|
||||
|
||||
/// Canonical-case spelling for a stage matched case-insensitively
|
||||
/// ("rejected" -> "Rejected", "start" -> Start). Unknown input is returned
|
||||
/// unchanged.
|
||||
QString canonical(const QString &stage);
|
||||
|
||||
/// Sankey column index. Start = 0; Applied..Offer walk the funnel; the three
|
||||
/// terminal outcomes (Accepted/Rejected/Withdrawn/Ghosted) share the last
|
||||
/// column so a rejection right after Applied is still a valid (longer) link.
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <[email protected]>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "kareer-version.h"
|
||||
|
||||
#include "clicommands.h"
|
||||
@@ -20,24 +25,37 @@ using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
// Filter out a couple of well-known benign framework artifacts rather than
|
||||
// spamming every run. Everything else is passed through untouched.
|
||||
static QtMessageHandler s_defaultMessageHandler = nullptr;
|
||||
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||||
static bool isBenignFrameworkNoise(const QString &message)
|
||||
{
|
||||
// Qt Quick emits this while Kirigami's PageRow incubates pages. It fires even
|
||||
// for a trivial empty page, is harmless, and cannot be avoided from app code.
|
||||
if (message.contains(QLatin1String("was not placed in the graphics scene"))) {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
// Malformed path data in third-party icon SVGs (system/app icon themes) is not
|
||||
// actionable from here; drop the noise rather than spam every render.
|
||||
if (context.category && qstrcmp(context.category, "qt.svg") == 0) {
|
||||
return;
|
||||
// An internal PageRow/StackView implementation detail, not something app
|
||||
// code can influence.
|
||||
if (message.contains(QLatin1String("StackView has detected conflicting anchors"))) {
|
||||
return true;
|
||||
}
|
||||
// Qt's Wayland integration tries to self-register with xdg-desktop-portal for
|
||||
// optional desktop features (global shortcuts, background). Kareer doesn't use
|
||||
// any of those, and it fires harmlessly on hosts where portal app-info
|
||||
// resolution is finicky.
|
||||
if (message.contains(QLatin1String("Failed to register with host portal"))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static QtMessageHandler s_defaultMessageHandler = nullptr;
|
||||
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message)
|
||||
{
|
||||
if (isBenignFrameworkNoise(message)) {
|
||||
return;
|
||||
}
|
||||
// Malformed path data in third-party icon SVGs (system/app icon themes) is not
|
||||
// actionable from here; drop the noise rather than spam every render.
|
||||
if (context.category && qstrcmp(context.category, "qt.svg") == 0) {
|
||||
return;
|
||||
}
|
||||
if (s_defaultMessageHandler) {
|
||||
@@ -87,6 +105,20 @@ int main(int argc, char *argv[])
|
||||
QQmlApplicationEngine engine;
|
||||
KLocalization::setupLocalizedContext(&engine);
|
||||
|
||||
QObject::connect(&engine, &QQmlApplicationEngine::warnings, &engine, [](const QList<QQmlError> &warnings) {
|
||||
for (const QQmlError &error : warnings) {
|
||||
if (isBenignFrameworkNoise(error.description())) {
|
||||
continue;
|
||||
}
|
||||
fprintf(stderr, "QML-WARNING: %s\n", qPrintable(error.toString()));
|
||||
}
|
||||
fflush(stderr);
|
||||
});
|
||||
QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &engine, [](const QUrl &url) {
|
||||
fprintf(stderr, "QML-OBJECT-CREATION-FAILED: %s\n", qPrintable(url.toString()));
|
||||
fflush(stderr);
|
||||
});
|
||||
|
||||
engine.loadFromModule("io.github.toservetheking.Kareer", u"Main"_s);
|
||||
if (engine.rootObjects().isEmpty()) {
|
||||
return -1;
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kirigamiaddons.formcard as FormCard
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
FormCard.FormCardDialog {
|
||||
id: root
|
||||
|
||||
required property JobsModel jobsModel
|
||||
property int editingJobId: -1
|
||||
|
||||
title: editingJobId < 0 ? i18nc("@title:dialog", "Add Application") : i18nc("@title:dialog", "Edit Application")
|
||||
standardButtons: QQC2.Dialog.Save | QQC2.Dialog.Cancel
|
||||
|
||||
function openForAdd(): void {
|
||||
editingJobId = -1;
|
||||
companyField.text = "";
|
||||
titleField.text = "";
|
||||
locationField.text = "";
|
||||
remoteCombo.currentIndex = 0;
|
||||
sourceField.text = "";
|
||||
urlField.text = "";
|
||||
dateField.value = new Date();
|
||||
salaryMinField.value = 0;
|
||||
salaryMaxField.value = 0;
|
||||
salaryExpectationField.value = 0;
|
||||
currencyField.text = "USD";
|
||||
contactField.text = "";
|
||||
notesField.text = "";
|
||||
const stages = root.jobsModel.stages;
|
||||
stageCombo.currentIndex = stages.indexOf("Applied");
|
||||
errorLabel.text = "";
|
||||
root.open();
|
||||
}
|
||||
|
||||
function openForEdit(id: int): void {
|
||||
editingJobId = id;
|
||||
const data = root.jobsModel.jobData(id);
|
||||
companyField.text = data.company;
|
||||
titleField.text = data.title;
|
||||
locationField.text = data.location;
|
||||
remoteCombo.currentIndex = Math.max(0, remoteCombo.model.indexOf(data.remoteType));
|
||||
sourceField.text = data.source;
|
||||
urlField.text = data.url;
|
||||
dateField.value = data.dateApplied;
|
||||
salaryMinField.value = data.salaryMin > 0 ? data.salaryMin : 0;
|
||||
salaryMaxField.value = data.salaryMax > 0 ? data.salaryMax : 0;
|
||||
salaryExpectationField.value = data.salaryExpectation > 0 ? data.salaryExpectation : 0;
|
||||
currencyField.text = data.currency;
|
||||
contactField.text = data.contact;
|
||||
notesField.text = data.notes;
|
||||
const stages = root.jobsModel.stages;
|
||||
stageCombo.currentIndex = Math.max(0, stages.indexOf(data.stage));
|
||||
errorLabel.text = "";
|
||||
root.open();
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
const fields = {
|
||||
company: companyField.text,
|
||||
title: titleField.text,
|
||||
location: locationField.text,
|
||||
remoteType: remoteCombo.currentIndex === 0 ? "" : remoteCombo.currentText,
|
||||
source: sourceField.text,
|
||||
url: urlField.text,
|
||||
dateApplied: dateField.value,
|
||||
salaryMin: salaryMinField.value > 0 ? salaryMinField.value : -1,
|
||||
salaryMax: salaryMaxField.value > 0 ? salaryMaxField.value : -1,
|
||||
salaryExpectation: salaryExpectationField.value > 0 ? salaryExpectationField.value : -1,
|
||||
currency: currencyField.text,
|
||||
contact: contactField.text,
|
||||
notes: notesField.text,
|
||||
stage: stageCombo.currentText,
|
||||
};
|
||||
|
||||
const ok = root.editingJobId < 0 ? root.jobsModel.addJob(fields) : root.jobsModel.updateJob(root.editingJobId, fields);
|
||||
if (!ok) {
|
||||
errorLabel.text = root.jobsModel.lastError();
|
||||
root.open();
|
||||
}
|
||||
}
|
||||
|
||||
FormCard.FormCard {
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: companyField
|
||||
label: i18nc("@label:textbox", "Company")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: titleField
|
||||
label: i18nc("@label:textbox", "Job Title")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: locationField
|
||||
label: i18nc("@label:textbox", "Location")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormComboBoxDelegate {
|
||||
id: remoteCombo
|
||||
text: i18nc("@label:listbox", "Remote Type")
|
||||
model: ["Unspecified", "Onsite", "Hybrid", "Remote"]
|
||||
}
|
||||
}
|
||||
|
||||
FormCard.FormCard {
|
||||
FormCard.FormComboBoxDelegate {
|
||||
id: stageCombo
|
||||
text: i18nc("@label:listbox", "Stage")
|
||||
model: root.jobsModel.stages
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormHeader {
|
||||
title: i18nc("@title:group", "Date Applied")
|
||||
}
|
||||
FormCard.FormDateTimeDelegate {
|
||||
id: dateField
|
||||
dateTimeDisplay: FormCard.FormDateTimeDelegate.DateTimeDisplay.Date
|
||||
}
|
||||
}
|
||||
|
||||
FormCard.FormCard {
|
||||
FormCard.FormSpinBoxDelegate {
|
||||
id: salaryMinField
|
||||
label: i18nc("@label:spinbox", "Salary Range Minimum")
|
||||
from: 0
|
||||
to: 5000000
|
||||
stepSize: 1000
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormSpinBoxDelegate {
|
||||
id: salaryMaxField
|
||||
label: i18nc("@label:spinbox", "Salary Range Maximum")
|
||||
from: 0
|
||||
to: 5000000
|
||||
stepSize: 1000
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormSpinBoxDelegate {
|
||||
id: salaryExpectationField
|
||||
label: i18nc("@label:spinbox", "Your Salary Expectation")
|
||||
from: 0
|
||||
to: 5000000
|
||||
stepSize: 1000
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: currencyField
|
||||
label: i18nc("@label:textbox", "Currency")
|
||||
}
|
||||
}
|
||||
|
||||
FormCard.FormCard {
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: sourceField
|
||||
label: i18nc("@label:textbox", "Source")
|
||||
placeholderText: i18nc("@info:placeholder", "Referral, LinkedIn, company site...")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: urlField
|
||||
label: i18nc("@label:textbox", "Job Posting URL")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextFieldDelegate {
|
||||
id: contactField
|
||||
label: i18nc("@label:textbox", "Contact")
|
||||
}
|
||||
FormCard.FormDelegateSeparator {}
|
||||
FormCard.FormTextAreaDelegate {
|
||||
id: notesField
|
||||
label: i18nc("@label:textbox", "Notes / Expectations")
|
||||
}
|
||||
}
|
||||
|
||||
FormCard.FormCard {
|
||||
visible: root.editingJobId >= 0
|
||||
FormCard.FormButtonDelegate {
|
||||
text: i18nc("@action:button", "Delete Application")
|
||||
icon.name: "edit-delete"
|
||||
onClicked: deleteConfirmDialog.open()
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
id: errorLabel
|
||||
Layout.fillWidth: true
|
||||
Layout.margins: Kirigami.Units.smallSpacing
|
||||
type: Kirigami.MessageType.Error
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Kirigami.PromptDialog {
|
||||
id: deleteConfirmDialog
|
||||
title: i18nc("@title", "Delete Application")
|
||||
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
|
||||
standardButtons: QQC2.Dialog.Cancel
|
||||
|
||||
customFooterActions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Delete")
|
||||
icon.name: "edit-delete"
|
||||
onTriggered: {
|
||||
root.jobsModel.removeJob(root.editingJobId);
|
||||
deleteConfirmDialog.close();
|
||||
root.close();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
|
||||
// Kirigami.ListSectionHeader per category, flat rows indented under the
|
||||
// header and separated by Kirigami.Separator, no card/border around them.
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import Qt.labs.qmlmodels
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kitemmodels as KItemModels
|
||||
import org.kde.kirigamiaddons.dateandtime as DateTime
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ScrollablePage {
|
||||
id: root
|
||||
|
||||
required property JobsModel jobsModel
|
||||
property int editingJobId: -1
|
||||
|
||||
title: root.editingJobId < 0 ? i18nc("@title", "Add Application") : i18nc("@title", "Edit Application")
|
||||
|
||||
signal done()
|
||||
|
||||
leftPadding: 0
|
||||
rightPadding: 0
|
||||
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
|
||||
// page) already carries its own top padding, and stacking ours on top
|
||||
// of that left an oversized gap above "Company", the first category.
|
||||
bottomPadding: Kirigami.Units.gridUnit
|
||||
|
||||
JobEditModel {
|
||||
id: editModel
|
||||
jobsModel: root.jobsModel
|
||||
editingJobId: root.editingJobId
|
||||
}
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Cancel")
|
||||
icon.name: "dialog-cancel"
|
||||
onTriggered: root.done()
|
||||
},
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Delete")
|
||||
icon.name: "edit-delete"
|
||||
visible: root.editingJobId >= 0
|
||||
onTriggered: deleteConfirmDialog.open()
|
||||
},
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Save")
|
||||
icon.name: "document-save"
|
||||
onTriggered: {
|
||||
if (editModel.save()) {
|
||||
root.done();
|
||||
} else {
|
||||
errorLabel.text = editModel.lastError;
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 0
|
||||
|
||||
Kirigami.InlineMessage {
|
||||
id: errorLabel
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
||||
type: Kirigami.MessageType.Error
|
||||
visible: text.length > 0
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: editModel.categories
|
||||
|
||||
delegate: ColumnLayout {
|
||||
id: section
|
||||
|
||||
required property var modelData
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
|
||||
Kirigami.ListSectionHeader {
|
||||
Layout.fillWidth: true
|
||||
text: section.modelData.title
|
||||
}
|
||||
|
||||
KItemModels.KSortFilterProxyModel {
|
||||
id: catModel
|
||||
sourceModel: editModel
|
||||
filterRoleName: "categoryId"
|
||||
// No category id is a prefix of another, so this is an exact match.
|
||||
filterString: section.modelData.id
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
||||
spacing: Kirigami.Units.largeSpacing
|
||||
|
||||
Repeater {
|
||||
id: rowRepeater
|
||||
model: catModel
|
||||
|
||||
delegate: DelegateChooser {
|
||||
role: "rowType"
|
||||
|
||||
// Text
|
||||
DelegateChoice {
|
||||
roleValue: 0
|
||||
delegate: ColumnLayout {
|
||||
id: textD
|
||||
required property int index
|
||||
required property string label
|
||||
required property string value
|
||||
required property string placeholder
|
||||
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: textD.label
|
||||
}
|
||||
QQC2.TextField {
|
||||
Layout.fillWidth: true
|
||||
text: textD.value
|
||||
placeholderText: textD.placeholder
|
||||
onTextEdited: editModel.setValue(textD.sourceRow, text)
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: textD.index !== rowRepeater.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combo box
|
||||
DelegateChoice {
|
||||
roleValue: 1
|
||||
delegate: ColumnLayout {
|
||||
id: comboD
|
||||
required property int index
|
||||
required property string label
|
||||
required property string value
|
||||
required property var comboOptions
|
||||
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: comboD.label
|
||||
}
|
||||
QQC2.ComboBox {
|
||||
Layout.fillWidth: true
|
||||
model: comboD.comboOptions
|
||||
currentIndex: comboD.comboOptions.indexOf(comboD.value)
|
||||
onActivated: editModel.setValue(comboD.sourceRow, currentText)
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: comboD.index !== rowRepeater.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spin box
|
||||
DelegateChoice {
|
||||
roleValue: 2
|
||||
delegate: ColumnLayout {
|
||||
id: spinD
|
||||
required property int index
|
||||
required property string label
|
||||
required property var model
|
||||
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: spinD.label
|
||||
}
|
||||
QQC2.SpinBox {
|
||||
id: spinBox
|
||||
Layout.fillWidth: true
|
||||
from: spinD.model.spinMin
|
||||
to: spinD.model.spinMax
|
||||
stepSize: 1000
|
||||
// One-time init, not a persistent binding: this also
|
||||
// writes back on user edits, so binding "value" live to
|
||||
// spinD.model.value would be a binding loop.
|
||||
Component.onCompleted: value = spinD.model.value
|
||||
onValueChanged: editModel.setValue(spinD.sourceRow, value)
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: spinD.index !== rowRepeater.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date
|
||||
DelegateChoice {
|
||||
roleValue: 3
|
||||
delegate: ColumnLayout {
|
||||
id: dateD
|
||||
required property int index
|
||||
required property string label
|
||||
required property var value
|
||||
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: dateD.label
|
||||
}
|
||||
QQC2.Button {
|
||||
Layout.fillWidth: true
|
||||
text: Qt.formatDate(dateD.value, Qt.ISODate)
|
||||
icon.name: "view-calendar-day"
|
||||
onClicked: {
|
||||
datePopup.value = dateD.value;
|
||||
datePopup.open();
|
||||
}
|
||||
|
||||
DateTime.DatePopup {
|
||||
id: datePopup
|
||||
onAccepted: editModel.setValue(dateD.sourceRow, value)
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: dateD.index !== rowRepeater.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Text area
|
||||
DelegateChoice {
|
||||
roleValue: 4
|
||||
delegate: ColumnLayout {
|
||||
id: areaD
|
||||
required property int index
|
||||
required property string label
|
||||
required property string value
|
||||
readonly property int sourceRow: catModel.mapToSource(catModel.index(index, 0)).row
|
||||
Layout.fillWidth: true
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: areaD.label
|
||||
}
|
||||
QQC2.TextArea {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Kirigami.Units.gridUnit * 5
|
||||
wrapMode: TextEdit.Wrap
|
||||
text: areaD.value
|
||||
onTextChanged: editModel.setValue(areaD.sourceRow, text)
|
||||
}
|
||||
|
||||
Kirigami.Separator {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
visible: areaD.index !== rowRepeater.count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Kirigami.PromptDialog {
|
||||
id: deleteConfirmDialog
|
||||
title: i18nc("@title", "Delete Application")
|
||||
subtitle: i18nc("@info", "Are you sure you want to delete this application? This cannot be undone.")
|
||||
standardButtons: QQC2.Dialog.Cancel
|
||||
|
||||
customFooterActions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Delete")
|
||||
icon.name: "edit-delete"
|
||||
onTriggered: {
|
||||
editModel.deleteJob();
|
||||
deleteConfirmDialog.close();
|
||||
root.done();
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// Sidebar/detail two-column layout: a search-filtered list of applications
|
||||
// with a RoundedItemDelegate + SubtitleContentItem delegate, where the
|
||||
// currently-open entry stays highlighted.
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
@@ -12,7 +20,9 @@ Kirigami.ScrollablePage {
|
||||
id: root
|
||||
|
||||
required property JobsModel jobsModel
|
||||
required property var editDialog
|
||||
property int currentJobId: -1
|
||||
|
||||
signal editRequested(int jobId)
|
||||
|
||||
title: i18nc("@title", "Applications")
|
||||
|
||||
@@ -21,14 +31,6 @@ Kirigami.ScrollablePage {
|
||||
onTextChanged: filteredJobs.filterString = text
|
||||
}
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Add Application")
|
||||
icon.name: "list-add"
|
||||
onTriggered: root.editDialog.openForAdd()
|
||||
}
|
||||
]
|
||||
|
||||
KItemModels.KSortFilterProxyModel {
|
||||
id: filteredJobs
|
||||
sourceModel: root.jobsModel
|
||||
@@ -49,32 +51,17 @@ Kirigami.ScrollablePage {
|
||||
required property string company
|
||||
required property string title
|
||||
required property string stage
|
||||
required property var dateApplied
|
||||
required property int salaryMin
|
||||
required property int salaryMax
|
||||
required property string currency
|
||||
|
||||
text: jobDelegate.company
|
||||
icon.source: "network-workgroup-symbolic"
|
||||
highlighted: root.currentJobId === jobDelegate.jobId
|
||||
|
||||
contentItem: Delegates.SubtitleContentItem {
|
||||
itemDelegate: jobDelegate
|
||||
subtitle: {
|
||||
const parts = [jobDelegate.title, jobDelegate.stage];
|
||||
if (jobDelegate.dateApplied) {
|
||||
parts.push(Qt.formatDate(jobDelegate.dateApplied, "yyyy-MM-dd"));
|
||||
}
|
||||
if (jobDelegate.salaryMin >= 0 || jobDelegate.salaryMax >= 0) {
|
||||
let salary = jobDelegate.currency + " ";
|
||||
salary += jobDelegate.salaryMin >= 0 ? jobDelegate.salaryMin : "?";
|
||||
salary += "–";
|
||||
salary += jobDelegate.salaryMax >= 0 ? jobDelegate.salaryMax : "?";
|
||||
parts.push(salary);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
subtitle: jobDelegate.title + " · " + jobDelegate.stage
|
||||
}
|
||||
|
||||
onClicked: root.editDialog.openForEdit(jobDelegate.jobId)
|
||||
onClicked: root.editRequested(jobDelegate.jobId)
|
||||
}
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
@@ -83,7 +70,7 @@ Kirigami.ScrollablePage {
|
||||
visible: jobList.count === 0
|
||||
icon.name: "office-address-book-symbolic"
|
||||
text: i18n("No applications yet")
|
||||
explanation: i18n("Use the Add Application button to log your first one.")
|
||||
explanation: i18n("Use the Add Application button on the dashboard to log your first one.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
@@ -12,8 +17,18 @@ Kirigami.ScrollablePage {
|
||||
|
||||
required property JobsModel jobsModel
|
||||
|
||||
signal addRequested()
|
||||
|
||||
title: i18nc("@title", "Dashboard")
|
||||
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:button", "Add Application")
|
||||
icon.name: "list-add"
|
||||
onTriggered: root.addRequested()
|
||||
}
|
||||
]
|
||||
|
||||
StatsModel {
|
||||
id: statsModel
|
||||
}
|
||||
@@ -57,7 +72,7 @@ Kirigami.ScrollablePage {
|
||||
}
|
||||
QQC2.Label {
|
||||
text: statCard.modelData.label
|
||||
opacity: 0.7
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
wrapMode: Text.WordWrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.kirigamiaddons.formcard as FormCard
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ApplicationWindow {
|
||||
@@ -19,22 +25,73 @@ Kirigami.ApplicationWindow {
|
||||
id: jobsModel
|
||||
}
|
||||
|
||||
ApplicationEditDialog {
|
||||
id: editDialog
|
||||
jobsModel: jobsModel
|
||||
}
|
||||
// The job currently open in the edit form, so the sidebar can keep it highlighted.
|
||||
property int currentJobId: -1
|
||||
|
||||
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 22
|
||||
pageStack.defaultColumnWidth: Kirigami.Units.gridUnit * 16
|
||||
pageStack.globalToolBar.style: Kirigami.ApplicationHeaderStyle.ToolBar
|
||||
|
||||
// Two static columns (applications + dashboard) - no dynamic page pushing.
|
||||
globalDrawer: Kirigami.GlobalDrawer {
|
||||
isMenu: true
|
||||
actions: [
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:inmenu", "Preferences…")
|
||||
icon.name: "configure"
|
||||
onTriggered: root.pageStack.pushDialogLayer(settingsPageComponent, {
|
||||
width: root.width
|
||||
}, {
|
||||
width: Kirigami.Units.gridUnit * 24,
|
||||
height: Kirigami.Units.gridUnit * 20,
|
||||
modality: Qt.NonModal
|
||||
})
|
||||
},
|
||||
Kirigami.Action {
|
||||
text: i18nc("@action:inmenu", "About %1", root.title)
|
||||
icon.name: "help-about"
|
||||
onTriggered: root.pageStack.pushDialogLayer(aboutPageComponent, {
|
||||
width: root.width
|
||||
}, {
|
||||
width: Kirigami.Units.gridUnit * 30,
|
||||
height: Kirigami.Units.gridUnit * 30,
|
||||
modality: Qt.NonModal
|
||||
})
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Component {
|
||||
id: aboutPageComponent
|
||||
FormCard.AboutPage {}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: settingsPageComponent
|
||||
SettingsPage {}
|
||||
}
|
||||
|
||||
// Two static columns: the applications list (sidebar) and the dashboard.
|
||||
// "Add Application" (and clicking a row) replaces just the dashboard
|
||||
// column with the edit form - the sidebar and its list are untouched.
|
||||
pageStack.initialPage: [applicationsComponent, dashboardComponent]
|
||||
|
||||
function showDashboard(): void {
|
||||
root.currentJobId = -1;
|
||||
root.pageStack.currentIndex = 1;
|
||||
root.pageStack.replace(dashboardComponent);
|
||||
}
|
||||
|
||||
function showEditPage(jobId: int): void {
|
||||
root.currentJobId = jobId;
|
||||
root.pageStack.currentIndex = 1;
|
||||
root.pageStack.replace(editPageComponent, {editingJobId: jobId});
|
||||
}
|
||||
|
||||
Component {
|
||||
id: applicationsComponent
|
||||
ApplicationsPage {
|
||||
jobsModel: jobsModel
|
||||
editDialog: editDialog
|
||||
currentJobId: root.currentJobId
|
||||
onEditRequested: jobId => root.showEditPage(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +99,15 @@ Kirigami.ApplicationWindow {
|
||||
id: dashboardComponent
|
||||
DashboardPage {
|
||||
jobsModel: jobsModel
|
||||
onAddRequested: root.showEditPage(-1)
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: editPageComponent
|
||||
ApplicationEditPage {
|
||||
jobsModel: jobsModel
|
||||
onDone: root.showDashboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
@@ -13,21 +18,29 @@ Item {
|
||||
id: root
|
||||
|
||||
readonly property bool empty: sankeyModel.empty
|
||||
readonly property real nodeWidth: Math.round(Kirigami.Units.gridUnit * 0.75)
|
||||
readonly property real rowPadding: Kirigami.Units.gridUnit
|
||||
|
||||
SankeyModel {
|
||||
id: sankeyModel
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (root.width > 0 && root.height > 0) {
|
||||
sankeyModel.relayout(root.width, root.height);
|
||||
}
|
||||
sankeyModel.reload(root.width, root.height, root.nodeWidth, root.rowPadding);
|
||||
}
|
||||
|
||||
onWidthChanged: refresh()
|
||||
onHeightChanged: refresh()
|
||||
// Resizes only need fresh geometry, not a database re-read, and are
|
||||
// debounced so a window drag doesn't rebuild every delegate per pixel.
|
||||
onWidthChanged: relayoutTimer.restart()
|
||||
onHeightChanged: relayoutTimer.restart()
|
||||
Component.onCompleted: refresh()
|
||||
|
||||
Timer {
|
||||
id: relayoutTimer
|
||||
interval: 150
|
||||
onTriggered: sankeyModel.relayout(root.width, root.height, root.nodeWidth, root.rowPadding)
|
||||
}
|
||||
|
||||
Kirigami.PlaceholderMessage {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - Kirigami.Units.gridUnit * 4
|
||||
@@ -41,7 +54,6 @@ Item {
|
||||
model: sankeyModel.links
|
||||
delegate: Shape {
|
||||
required property var modelData
|
||||
asynchronous: true
|
||||
ShapePath {
|
||||
fillColor: modelData.color
|
||||
strokeColor: "transparent"
|
||||
@@ -74,14 +86,27 @@ Item {
|
||||
hoverEnabled: true
|
||||
}
|
||||
|
||||
// Subtle backing so the label stays legible where ribbons pass
|
||||
// underneath it.
|
||||
Rectangle {
|
||||
anchors.fill: labelHeading
|
||||
anchors.margins: -Kirigami.Units.smallSpacing / 2
|
||||
radius: 3
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
opacity: 0.6
|
||||
}
|
||||
|
||||
Kirigami.Heading {
|
||||
id: labelHeading
|
||||
level: 5
|
||||
anchors.left: parent.right
|
||||
anchors.left: nodeDelegate.modelData.labelOnRight ? parent.right : undefined
|
||||
anchors.right: nodeDelegate.modelData.labelOnRight ? undefined : parent.left
|
||||
anchors.leftMargin: Kirigami.Units.smallSpacing
|
||||
anchors.rightMargin: Kirigami.Units.smallSpacing
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: nodeDelegate.modelData.labelWidth
|
||||
width: Math.min(nodeDelegate.modelData.labelWidth, implicitWidth)
|
||||
horizontalAlignment: nodeDelegate.modelData.labelOnRight ? Text.AlignLeft : Text.AlignRight
|
||||
text: nodeDelegate.modelData.label
|
||||
visible: nodeDelegate.height >= Kirigami.Units.gridUnit
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// Styled after KDE System Settings' Audio page (plasma-pa's kcm/ui/main.qml):
|
||||
// Kirigami.ListSectionHeader per section, flat rows indented under the
|
||||
// header, no card/border around them.
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
import org.kde.kirigami as Kirigami
|
||||
import io.github.toservetheking.Kareer
|
||||
|
||||
Kirigami.ScrollablePage {
|
||||
id: root
|
||||
|
||||
title: i18nc("@title:window", "Preferences")
|
||||
|
||||
leftPadding: 0
|
||||
rightPadding: 0
|
||||
// No topPadding: Kirigami.ListSectionHeader (the first thing on the
|
||||
// page) already carries its own top padding, and stacking ours on top
|
||||
// of that left an oversized gap above "Appearance".
|
||||
bottomPadding: Kirigami.Units.gridUnit
|
||||
|
||||
ColumnLayout {
|
||||
width: root.width
|
||||
spacing: 0
|
||||
|
||||
Kirigami.ListSectionHeader {
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@title:group", "Appearance")
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: Kirigami.Units.largeSpacing
|
||||
Layout.rightMargin: Kirigami.Units.largeSpacing
|
||||
Layout.topMargin: Kirigami.Units.smallSpacing
|
||||
Layout.bottomMargin: Kirigami.Units.smallSpacing
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
QQC2.Label {
|
||||
Layout.fillWidth: true
|
||||
text: i18nc("@label:listbox", "Color scheme")
|
||||
}
|
||||
QQC2.ComboBox {
|
||||
id: colorSchemeCombo
|
||||
Layout.fillWidth: true
|
||||
|
||||
model: AppColorScheme.colorSchemesModel
|
||||
textRole: "display"
|
||||
|
||||
delegate: QQC2.ItemDelegate {
|
||||
id: schemeDelegate
|
||||
required property var model
|
||||
width: colorSchemeCombo.width
|
||||
|
||||
icon.source: "image://colorScheme/" + schemeDelegate.model.display
|
||||
icon.color: "transparent"
|
||||
text: schemeDelegate.model.display
|
||||
highlighted: schemeDelegate.model.display === AppColorScheme.activeColorSchemeName
|
||||
|
||||
onClicked: {
|
||||
AppColorScheme.activeColorSchemeName = schemeDelegate.model.display;
|
||||
colorSchemeCombo.popup.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the closed-box label in sync without fighting the popup's own selection state.
|
||||
displayText: AppColorScheme.activeColorSchemeName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "sankeymodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
@@ -10,6 +15,7 @@
|
||||
#include <QVariantMap>
|
||||
#include <algorithm>
|
||||
#include <iterator>
|
||||
#include <numeric>
|
||||
|
||||
using namespace Qt::Literals::StringLiterals;
|
||||
|
||||
@@ -57,25 +63,23 @@ bool SankeyModel::isEmpty() const
|
||||
return m_nodes.isEmpty();
|
||||
}
|
||||
|
||||
void SankeyModel::relayout(qreal width, qreal height)
|
||||
void SankeyModel::reload(qreal width, qreal height, qreal nodeWidth, qreal padding)
|
||||
{
|
||||
m_counts.clear();
|
||||
const QList<StageTransition> transitions = m_db.stageTransitions();
|
||||
for (const StageTransition &t : transitions) {
|
||||
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
|
||||
m_counts[{from, t.toStage}] += 1;
|
||||
}
|
||||
relayout(width, height, nodeWidth, padding);
|
||||
}
|
||||
|
||||
void SankeyModel::relayout(qreal width, qreal height, qreal nodeWidth, qreal padding)
|
||||
{
|
||||
m_nodes.clear();
|
||||
m_links.clear();
|
||||
|
||||
if (width <= 0 || height <= 0) {
|
||||
Q_EMIT changed();
|
||||
return;
|
||||
}
|
||||
|
||||
const QList<StageTransition> transitions = m_db.stageTransitions();
|
||||
|
||||
QHash<QPair<QString, QString>, int> counts;
|
||||
for (const StageTransition &t : transitions) {
|
||||
const QString from = t.fromStage.isEmpty() ? QString::fromLatin1(JobStage::Start) : t.fromStage;
|
||||
counts[{from, t.toStage}] += 1;
|
||||
}
|
||||
|
||||
if (counts.isEmpty()) {
|
||||
if (width <= 0 || height <= 0 || m_counts.isEmpty()) {
|
||||
Q_EMIT changed();
|
||||
return;
|
||||
}
|
||||
@@ -83,7 +87,7 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
QHash<QString, int> inbound;
|
||||
QHash<QString, int> outbound;
|
||||
QSet<QString> stageNames;
|
||||
for (auto it = counts.constBegin(); it != counts.constEnd(); ++it) {
|
||||
for (auto it = m_counts.constBegin(); it != m_counts.constEnd(); ++it) {
|
||||
const QString &from = it.key().first;
|
||||
const QString &to = it.key().second;
|
||||
outbound[from] += it.value();
|
||||
@@ -111,14 +115,23 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
});
|
||||
|
||||
QHash<int, QList<int>> columnIndices;
|
||||
int maxColumn = 0;
|
||||
for (int i = 0; i < nodeList.size(); ++i) {
|
||||
columnIndices[nodeList.at(i).column].append(i);
|
||||
maxColumn = qMax(maxColumn, nodeList.at(i).column);
|
||||
}
|
||||
|
||||
constexpr qreal nodeWidth = 16.0;
|
||||
constexpr qreal padding = 10.0;
|
||||
// Columns are spread by their rank among the columns actually present,
|
||||
// not their canonical index, so a sparse pipeline (say Start, Applied,
|
||||
// Rejected) still fills the width instead of leaving dead stretches for
|
||||
// the unused stages in between.
|
||||
QList<int> usedColumns = columnIndices.keys();
|
||||
std::sort(usedColumns.begin(), usedColumns.end());
|
||||
QHash<int, int> rankOfColumn;
|
||||
for (int i = 0; i < usedColumns.size(); ++i) {
|
||||
rankOfColumn.insert(usedColumns.at(i), i);
|
||||
}
|
||||
const int rankCount = usedColumns.size();
|
||||
|
||||
constexpr qreal minNodeHeight = 2.0;
|
||||
|
||||
// A single vertical scale is shared by every column: the column with the
|
||||
// largest total flow determines it, so no column can overflow the
|
||||
@@ -145,15 +158,59 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
scale = 1.0;
|
||||
}
|
||||
|
||||
// Tiny nodes get clamped up to minNodeHeight, which consumes height the
|
||||
// raw totals above didn't account for; shrink the shared scale until
|
||||
// every column fits with its clamped nodes included. Terminates because
|
||||
// the scale only decreases and the clamped set only grows.
|
||||
bool again = haveScale;
|
||||
while (again) {
|
||||
again = false;
|
||||
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
|
||||
const qreal gaps = padding * qMax(0, it.value().size() - 1);
|
||||
qreal clampedHeight = 0;
|
||||
qreal freeTotal = 0;
|
||||
for (int idx : it.value()) {
|
||||
const qreal value = nodeList.at(idx).value;
|
||||
if (value * scale < minNodeHeight) {
|
||||
clampedHeight += minNodeHeight;
|
||||
} else {
|
||||
freeTotal += value;
|
||||
}
|
||||
}
|
||||
if (freeTotal <= 0) {
|
||||
continue;
|
||||
}
|
||||
const qreal available = height - gaps - clampedHeight;
|
||||
if (available <= 0) {
|
||||
continue;
|
||||
}
|
||||
const qreal candidate = available / freeTotal;
|
||||
if (candidate < scale) {
|
||||
scale = candidate;
|
||||
again = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-column minimum node height: backs off from minNodeHeight when even
|
||||
// that many clamped nodes would overflow a very short viewport.
|
||||
QHash<int, qreal> minHeightByColumn;
|
||||
for (auto it = columnIndices.constBegin(); it != columnIndices.constEnd(); ++it) {
|
||||
const int count = it.value().size();
|
||||
const qreal gaps = padding * qMax(0, count - 1);
|
||||
minHeightByColumn.insert(it.key(), qMin(minNodeHeight, qMax<qreal>(0.5, (height - gaps) / count)));
|
||||
}
|
||||
|
||||
for (auto it = columnIndices.begin(); it != columnIndices.end(); ++it) {
|
||||
const QList<int> &idxs = it.value();
|
||||
qreal totalHeight = 0;
|
||||
for (int idx : idxs) {
|
||||
totalHeight += qMax<qreal>(nodeList.at(idx).value * scale, 2.0);
|
||||
}
|
||||
const qreal gaps = padding * qMax(0, idxs.size() - 1);
|
||||
const qreal startY = qMax<qreal>(0.0, (height - totalHeight - gaps) / 2.0);
|
||||
const qreal x = maxColumn > 0 ? (it.key() * (width - nodeWidth) / maxColumn) : 0.0;
|
||||
const qreal minHeight = minHeightByColumn.value(it.key());
|
||||
// Columns are top-aligned rather than centered: the funnel's success
|
||||
// path then runs level along the top while drop-off ribbons peel
|
||||
// downward into the open space beneath it, instead of every column
|
||||
// being centered and the ribbons weaving up and down to meet.
|
||||
const qreal startY = 0.0;
|
||||
const int rank = rankOfColumn.value(it.key());
|
||||
const qreal x = rankCount > 1 ? rank * (width - nodeWidth) / (rankCount - 1) : (width - nodeWidth) / 2.0;
|
||||
|
||||
qreal cursorY = startY;
|
||||
for (int idx : idxs) {
|
||||
@@ -161,7 +218,7 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
n.x = x;
|
||||
n.y = cursorY;
|
||||
n.width = nodeWidth;
|
||||
n.height = qMax<qreal>(n.value * scale, 2.0);
|
||||
n.height = qMax(n.value * scale, minHeight);
|
||||
cursorY += n.height + padding;
|
||||
}
|
||||
}
|
||||
@@ -171,14 +228,7 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
nodeIndexByStage.insert(nodeList.at(i).stage, i);
|
||||
}
|
||||
|
||||
QHash<QString, qreal> sourceCursor;
|
||||
QHash<QString, qreal> targetCursor;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
sourceCursor.insert(n.stage, n.y);
|
||||
targetCursor.insert(n.stage, n.y);
|
||||
}
|
||||
|
||||
QList<QPair<QString, QString>> linkKeys = counts.keys();
|
||||
QList<QPair<QString, QString>> linkKeys = m_counts.keys();
|
||||
std::sort(linkKeys.begin(), linkKeys.end(), [&](const QPair<QString, QString> &a, const QPair<QString, QString> &b) {
|
||||
const int aFrom = nodeIndexByStage.value(a.first);
|
||||
const int bFrom = nodeIndexByStage.value(b.first);
|
||||
@@ -188,22 +238,107 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
return nodeIndexByStage.value(a.second) < nodeIndexByStage.value(b.second);
|
||||
});
|
||||
|
||||
// Ribbons share the node's vertical scale, but their minimum visible
|
||||
// thickness can add up past the node it stacks against; total the raw
|
||||
// thicknesses per node and side first, then squeeze each ribbon by its
|
||||
// endpoints' overflow so the stack always stays inside both nodes.
|
||||
QList<qreal> rawThickness;
|
||||
rawThickness.reserve(linkKeys.size());
|
||||
QHash<QString, qreal> outboundThickness;
|
||||
QHash<QString, qreal> inboundThickness;
|
||||
for (const auto &key : std::as_const(linkKeys)) {
|
||||
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first));
|
||||
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second));
|
||||
const qreal minThickness = qMin<qreal>(1.5, qMin(minHeightByColumn.value(fromNode.column), minHeightByColumn.value(toNode.column)));
|
||||
const qreal thickness = qMax(m_counts.value(key) * scale, minThickness);
|
||||
rawThickness.append(thickness);
|
||||
outboundThickness[key.first] += thickness;
|
||||
inboundThickness[key.second] += thickness;
|
||||
}
|
||||
|
||||
QList<qreal> finalThickness;
|
||||
finalThickness.reserve(linkKeys.size());
|
||||
for (int i = 0; i < linkKeys.size(); ++i) {
|
||||
const QPair<QString, QString> &key = linkKeys.at(i);
|
||||
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(key.first));
|
||||
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(key.second));
|
||||
const qreal outFactor = qMin<qreal>(1.0, fromNode.height / outboundThickness.value(key.first));
|
||||
const qreal inFactor = qMin<qreal>(1.0, toNode.height / inboundThickness.value(key.second));
|
||||
finalThickness.append(rawThickness.at(i) * qMin(outFactor, inFactor));
|
||||
}
|
||||
|
||||
// Each end of a ribbon gets its slot on the node independently: outgoing
|
||||
// ribbons stack in order of their target's height, incoming ones in
|
||||
// order of their source's height (d3-sankey style). One global order for
|
||||
// both ends would let a low slot head for a high target and twist over
|
||||
// its siblings.
|
||||
const auto nodeCenter = [&](const QString &stage) {
|
||||
const NodeInfo &n = nodeList.at(nodeIndexByStage.value(stage));
|
||||
return n.y + n.height / 2.0;
|
||||
};
|
||||
QList<int> order(linkKeys.size());
|
||||
std::iota(order.begin(), order.end(), 0);
|
||||
|
||||
QList<qreal> sourceYTop(linkKeys.size());
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b) {
|
||||
const QPair<QString, QString> &ka = linkKeys.at(a);
|
||||
const QPair<QString, QString> &kb = linkKeys.at(b);
|
||||
if (ka.first != kb.first) {
|
||||
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
|
||||
}
|
||||
const qreal ya = nodeCenter(ka.second);
|
||||
const qreal yb = nodeCenter(kb.second);
|
||||
if (!qFuzzyCompare(ya, yb)) {
|
||||
return ya < yb;
|
||||
}
|
||||
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
|
||||
});
|
||||
QHash<QString, qreal> sourceCursor;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
sourceCursor.insert(n.stage, n.y);
|
||||
}
|
||||
for (int i : std::as_const(order)) {
|
||||
sourceYTop[i] = sourceCursor.value(linkKeys.at(i).first);
|
||||
sourceCursor[linkKeys.at(i).first] = sourceYTop.at(i) + finalThickness.at(i);
|
||||
}
|
||||
|
||||
QList<qreal> targetYTop(linkKeys.size());
|
||||
std::sort(order.begin(), order.end(), [&](int a, int b) {
|
||||
const QPair<QString, QString> &ka = linkKeys.at(a);
|
||||
const QPair<QString, QString> &kb = linkKeys.at(b);
|
||||
if (ka.second != kb.second) {
|
||||
return nodeIndexByStage.value(ka.second) < nodeIndexByStage.value(kb.second);
|
||||
}
|
||||
const qreal ya = nodeCenter(ka.first);
|
||||
const qreal yb = nodeCenter(kb.first);
|
||||
if (!qFuzzyCompare(ya, yb)) {
|
||||
return ya < yb;
|
||||
}
|
||||
return nodeIndexByStage.value(ka.first) < nodeIndexByStage.value(kb.first);
|
||||
});
|
||||
QHash<QString, qreal> targetCursor;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
targetCursor.insert(n.stage, n.y);
|
||||
}
|
||||
for (int i : std::as_const(order)) {
|
||||
targetYTop[i] = targetCursor.value(linkKeys.at(i).second);
|
||||
targetCursor[linkKeys.at(i).second] = targetYTop.at(i) + finalThickness.at(i);
|
||||
}
|
||||
|
||||
for (int i = 0; i < linkKeys.size(); ++i) {
|
||||
const QPair<QString, QString> &key = linkKeys.at(i);
|
||||
const QString &from = key.first;
|
||||
const QString &to = key.second;
|
||||
const int value = counts.value(key);
|
||||
const qreal thickness = qMax<qreal>(value * scale, 1.5);
|
||||
const int value = m_counts.value(key);
|
||||
|
||||
const NodeInfo &fromNode = nodeList.at(nodeIndexByStage.value(from));
|
||||
const NodeInfo &toNode = nodeList.at(nodeIndexByStage.value(to));
|
||||
|
||||
const qreal y0Top = sourceCursor.value(from);
|
||||
const qreal thickness = finalThickness.at(i);
|
||||
const qreal y0Top = sourceYTop.at(i);
|
||||
const qreal y0Bottom = y0Top + thickness;
|
||||
sourceCursor[from] = y0Bottom;
|
||||
|
||||
const qreal y1Top = targetCursor.value(to);
|
||||
const qreal y1Top = targetYTop.at(i);
|
||||
const qreal y1Bottom = y1Top + thickness;
|
||||
targetCursor[to] = y1Bottom;
|
||||
|
||||
const qreal x0 = fromNode.x + fromNode.width;
|
||||
const qreal x1 = toNode.x;
|
||||
@@ -221,14 +356,18 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
{u"value"_s, value},
|
||||
{u"pathData"_s, path},
|
||||
{u"color"_s, linkColor},
|
||||
{u"thickness"_s, thickness},
|
||||
{u"sourceY"_s, y0Top},
|
||||
{u"targetY"_s, y1Top},
|
||||
});
|
||||
}
|
||||
|
||||
// Labels are drawn to the right of each node. Without a bound, a long
|
||||
// label (e.g. "Applications") can run into the next column's node and
|
||||
// its own label. Cap each node's label to the gap before the next
|
||||
// distinct column actually in use (skipping empty columns), so text
|
||||
// elides instead of overlapping.
|
||||
// Labels sit to the right of each node except in the last used column,
|
||||
// whose labels go to the left (d3-sankey style) so nothing ever renders
|
||||
// past the right edge. Each label is capped to the gap before the next
|
||||
// column in use, and the final gap is split between the right-side label
|
||||
// of the penultimate column and the left-side label of the last one, so
|
||||
// text elides instead of overlapping.
|
||||
QList<qreal> columnStarts;
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
if (!columnStarts.contains(n.x)) {
|
||||
@@ -237,12 +376,29 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
}
|
||||
std::sort(columnStarts.begin(), columnStarts.end());
|
||||
|
||||
constexpr qreal labelMargin = 8.0;
|
||||
constexpr qreal minLabelWidth = 24.0;
|
||||
|
||||
for (const NodeInfo &n : std::as_const(nodeList)) {
|
||||
const QString label = n.stage == QLatin1String(JobStage::Start) ? i18n("Applications") : i18n(n.stage.toUtf8().constData());
|
||||
|
||||
const int columnPos = static_cast<int>(std::distance(columnStarts.begin(), std::find(columnStarts.begin(), columnStarts.end(), n.x)));
|
||||
const qreal nextColumnX = columnPos + 1 < columnStarts.size() ? columnStarts.at(columnPos + 1) : width;
|
||||
const qreal labelWidth = qMax<qreal>(24.0, nextColumnX - (n.x + n.width) - 8.0);
|
||||
const int lastPos = columnStarts.size() - 1;
|
||||
const bool labelOnRight = columnPos < lastPos || lastPos == 0;
|
||||
qreal labelWidth = 0;
|
||||
if (lastPos == 0) {
|
||||
labelWidth = width - (n.x + n.width) - labelMargin;
|
||||
} else if (columnPos < lastPos) {
|
||||
const qreal gap = columnStarts.at(columnPos + 1) - (n.x + n.width);
|
||||
// The outcome names in the last column run longer than the
|
||||
// penultimate stage's, so they get the bigger share of the gap.
|
||||
const qreal share = columnPos == lastPos - 1 ? 0.4 : 1.0;
|
||||
labelWidth = gap * share - 2 * labelMargin;
|
||||
} else {
|
||||
const qreal gap = n.x - (columnStarts.at(columnPos - 1) + n.width);
|
||||
labelWidth = gap * 0.6 - 2 * labelMargin;
|
||||
}
|
||||
labelWidth = qMax(labelWidth, minLabelWidth);
|
||||
|
||||
m_nodes.append(QVariantMap{
|
||||
{u"stage"_s, n.stage},
|
||||
@@ -254,6 +410,7 @@ void SankeyModel::relayout(qreal width, qreal height)
|
||||
{u"value"_s, n.value},
|
||||
{u"color"_s, JobStage::color(n.stage)},
|
||||
{u"labelWidth"_s, labelWidth},
|
||||
{u"labelOnRight"_s, labelOnRight},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QPair>
|
||||
#include <QQmlEngine>
|
||||
#include <QVariantList>
|
||||
|
||||
@@ -34,15 +41,20 @@ public:
|
||||
bool isEmpty() const;
|
||||
|
||||
public Q_SLOTS:
|
||||
/// Recomputes node/link geometry to fit within (width, height) logical
|
||||
/// pixels. Call whenever the data or the available viewport changes.
|
||||
void relayout(qreal width, qreal height);
|
||||
/// Re-reads the stage history from the database, then lays it out to fit
|
||||
/// within (width, height) logical pixels. Call whenever the data changes.
|
||||
void reload(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
|
||||
|
||||
/// Recomputes node/link geometry from the cached stage history. Call on
|
||||
/// viewport resizes; unlike reload() this never touches the database.
|
||||
void relayout(qreal width, qreal height, qreal nodeWidth = 16.0, qreal padding = 10.0);
|
||||
|
||||
Q_SIGNALS:
|
||||
void changed();
|
||||
|
||||
private:
|
||||
JobsDatabase m_db;
|
||||
QHash<QPair<QString, QString>, int> m_counts;
|
||||
QVariantList m_nodes;
|
||||
QVariantList m_links;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#include "statsmodel.h"
|
||||
#include "jobstage.h"
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
/*
|
||||
SPDX-FileCopyrightText: 2026 ToServeTheKing <austin@thebennett.net>
|
||||
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "jobsdatabase.h"
|
||||
|
||||