Skip to content

Commit ebcf742

Browse files
committed
feat(github-contributions): add GitHub contribution graph desktop widget
1 parent ea21cb6 commit ebcf742

14 files changed

Lines changed: 935 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import QtQuick
2+
import QtQuick.Layouts
3+
import qs.Commons
4+
import qs.Widgets
5+
import qs.Modules.DesktopWidgets
6+
7+
DraggableDesktopWidget {
8+
id: root
9+
property var pluginApi: null
10+
11+
readonly property var mainInstance: pluginApi?.mainInstance
12+
property var cfg: pluginApi?.pluginSettings || ({})
13+
property var defaults: pluginApi?.manifest?.metadata?.defaultSettings || ({})
14+
15+
readonly property int weeksShown: Math.max(4, Math.min(53, cfg.weeksShown ?? defaults.weeksShown ?? 52))
16+
readonly property bool showTotal: cfg.showTotal ?? defaults.showTotal ?? true
17+
readonly property bool showLabels: cfg.showLabels ?? defaults.showLabels ?? true
18+
19+
readonly property bool hasData: mainInstance?.hasData ?? false
20+
readonly property var allDays: mainInstance?.days ?? []
21+
22+
readonly property var slicedDays: {
23+
if (!allDays.length) return []
24+
var lastWd = weekdayOf(allDays[allDays.length - 1].date)
25+
var keep = (weeksShown - 1) * 7 + lastWd + 1
26+
return allDays.length > keep ? allDays.slice(allDays.length - keep) : allDays
27+
}
28+
readonly property int padOffset: slicedDays.length ? weekdayOf(slicedDays[0].date) : 0
29+
readonly property int columns: Math.ceil((padOffset + slicedDays.length) / 7)
30+
property int hoveredIndex: -1
31+
32+
readonly property var monthLabels: {
33+
var labels = []
34+
var prevMonth = -1
35+
for (var i = 0; i < slicedDays.length; i++) {
36+
var idx = padOffset + i
37+
if (idx % 7 !== 0) continue
38+
var month = parseInt(slicedDays[i].date.substring(5, 7))
39+
if (month !== prevMonth && prevMonth !== -1 && idx / 7 <= columns - 3)
40+
labels.push({ "col": idx / 7, "name": Qt.locale().standaloneMonthName(month - 1, Locale.ShortFormat) })
41+
prevMonth = month
42+
}
43+
return labels
44+
}
45+
46+
readonly property real cellStep: 13 * widgetScale
47+
readonly property real cellSize: 10 * widgetScale
48+
readonly property real dayLabelWidth: showLabels ? 30 * widgetScale : 0
49+
readonly property real contentMargin: Style.marginL * widgetScale
50+
51+
implicitWidth: hasData ? dayLabelWidth + columns * cellStep - (3 * widgetScale) + contentMargin * 2 : 240 * widgetScale
52+
implicitHeight: contentColumn.implicitHeight + contentMargin * 2
53+
width: implicitWidth
54+
height: implicitHeight
55+
56+
function weekdayOf(dateStr) {
57+
var p = dateStr.split("-")
58+
return new Date(parseInt(p[0]), parseInt(p[1]) - 1, parseInt(p[2])).getDay()
59+
}
60+
61+
function cellColor(level) {
62+
if (level <= 0) return Qt.alpha(Color.mOnSurface, 0.08)
63+
return Qt.alpha(Color.mPrimary, [0.25, 0.45, 0.7, 1.0][Math.min(level, 4) - 1])
64+
}
65+
66+
function footerText() {
67+
// Hover needs Noctalia >= 4.7.2; older shells keep desktop widgets click-through
68+
if (hoveredIndex >= 0 && hoveredIndex < slicedDays.length) {
69+
var day = slicedDays[hoveredIndex]
70+
var date = new Date(day.date + "T12:00:00").toLocaleDateString(Qt.locale(), Locale.ShortFormat)
71+
if (day.count === 0 || day.level === 0) return pluginApi?.tr("desktop.cellNone", { "date": date })
72+
if (day.count < 0) return pluginApi?.tr("desktop.cellSomeUnknown", { "date": date })
73+
return pluginApi?.tr("desktop.cellSome", { "count": day.count, "date": date })
74+
}
75+
var name = "@" + (mainInstance?.username ?? "")
76+
if (!allDays.length) return name
77+
// The calendar rolls over at UTC, so the last cell isn't always today locally
78+
var last = allDays[allDays.length - 1]
79+
var today = Qt.formatDate(new Date(), "yyyy-MM-dd")
80+
if (last.date !== today || last.count < 0) return name
81+
return name + " · " + pluginApi?.tr("desktop.todayCount", { "count": last.count })
82+
}
83+
84+
ColumnLayout {
85+
id: contentColumn
86+
anchors.fill: parent
87+
anchors.margins: root.contentMargin
88+
spacing: Style.marginS * root.widgetScale
89+
90+
// Total + streak header
91+
RowLayout {
92+
Layout.fillWidth: true
93+
visible: root.showTotal && root.hasData
94+
spacing: Style.marginM * root.widgetScale
95+
96+
NText {
97+
text: pluginApi?.tr("desktop.total", { "total": root.mainInstance?.totalContributions ?? 0 })
98+
pointSize: Style.fontSizeS * root.widgetScale
99+
font.weight: Font.Bold
100+
color: Color.mOnSurface
101+
}
102+
103+
Item { Layout.fillWidth: true }
104+
105+
NText {
106+
visible: (root.mainInstance?.currentStreak ?? 0) > 0
107+
text: pluginApi?.tr("desktop.streak", { "count": root.mainInstance?.currentStreak ?? 0 })
108+
pointSize: Style.fontSizeXS * root.widgetScale
109+
color: Color.mOnSurfaceVariant
110+
}
111+
}
112+
113+
// Month labels
114+
Item {
115+
Layout.fillWidth: true
116+
visible: root.showLabels && root.hasData
117+
implicitHeight: Style.fontSizeXS * root.widgetScale * 1.6
118+
119+
Repeater {
120+
model: root.monthLabels
121+
NText {
122+
x: root.dayLabelWidth + modelData.col * root.cellStep
123+
text: modelData.name
124+
pointSize: Style.fontSizeXS * root.widgetScale
125+
color: Color.mOnSurfaceVariant
126+
}
127+
}
128+
}
129+
130+
// Heatmap grid with weekday labels
131+
RowLayout {
132+
visible: root.hasData
133+
spacing: 0
134+
135+
Item {
136+
visible: root.showLabels
137+
implicitWidth: root.dayLabelWidth
138+
implicitHeight: gridArea.implicitHeight
139+
140+
Repeater {
141+
model: [1, 3, 5]
142+
NText {
143+
y: modelData * root.cellStep + (root.cellSize - height) / 2
144+
text: Qt.locale().dayName(modelData, Locale.ShortFormat)
145+
pointSize: Style.fontSizeXXS * root.widgetScale
146+
color: Color.mOnSurfaceVariant
147+
}
148+
}
149+
}
150+
151+
Item {
152+
id: gridArea
153+
implicitWidth: root.columns * root.cellStep - (3 * root.widgetScale)
154+
implicitHeight: 7 * root.cellStep - (3 * root.widgetScale)
155+
156+
Repeater {
157+
model: root.slicedDays
158+
Rectangle {
159+
readonly property int gridIndex: index + root.padOffset
160+
x: Math.floor(gridIndex / 7) * root.cellStep
161+
y: (gridIndex % 7) * root.cellStep
162+
width: root.cellSize
163+
height: root.cellSize
164+
radius: 2 * root.widgetScale
165+
color: root.cellColor(modelData.level)
166+
border.width: root.hoveredIndex === index ? Math.max(1, root.widgetScale) : 0
167+
border.color: Color.mOnSurface
168+
}
169+
}
170+
171+
MouseArea {
172+
anchors.fill: parent
173+
hoverEnabled: true
174+
acceptedButtons: Qt.NoButton
175+
onPositionChanged: mouse => {
176+
var col = Math.floor(mouse.x / root.cellStep)
177+
var row = Math.floor(mouse.y / root.cellStep)
178+
var i = col * 7 + row - root.padOffset
179+
root.hoveredIndex = (row >= 0 && row < 7 && i >= 0 && i < root.slicedDays.length) ? i : -1
180+
}
181+
onExited: root.hoveredIndex = -1
182+
}
183+
}
184+
}
185+
186+
// Username and today's count
187+
NText {
188+
Layout.fillWidth: true
189+
visible: root.hasData
190+
text: root.footerText()
191+
pointSize: Style.fontSizeXS * root.widgetScale
192+
color: Color.mOnSurfaceVariant
193+
elide: Text.ElideRight
194+
}
195+
196+
// Placeholder when not configured or loading
197+
NText {
198+
Layout.alignment: Qt.AlignHCenter
199+
Layout.margins: Style.marginL * root.widgetScale
200+
visible: !root.hasData
201+
text: {
202+
if (root.mainInstance?.errorMessage) return root.mainInstance.errorMessage
203+
if (root.mainInstance?.loading) return pluginApi?.tr("desktop.loading")
204+
return pluginApi?.tr("errors.noUsername")
205+
}
206+
pointSize: Style.fontSizeS * root.widgetScale
207+
color: Color.mOnSurfaceVariant
208+
}
209+
}
210+
}

0 commit comments

Comments
 (0)