-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathPanel.qml
More file actions
1675 lines (1508 loc) · 59 KB
/
Copy pathPanel.qml
File metadata and controls
1675 lines (1508 loc) · 59 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import Quickshell
import Quickshell.Io
import QtQuick
import QtQuick.Shapes
import qs.Commons
// Omasweeper, Minesweeper for omarchy-shell. Summoned/toggled through the shell host:
// omarchy-shell shell toggle jankeesvw.omasweeper
// The host calls open(payloadJson) / close() and reads `opened`; it also
// injects `shell` right after the Loader resolves (see onShellChanged).
//
// Nothing is bundled: the board, the numbers, the flags and the mines are all
// drawn here and coloured from the live theme, so the whole thing recolours
// with the desktop.
//
// The model is four flat arrays indexed by cell, where a cell is
// `col + row * cols`, plus a handful of counters. Every mutation copies the
// array it touches and assigns the copy back, because a QML `var` property
// only notifies on assignment: mutating in place would leave the board
// showing the previous move. That copy is also what makes undo-free
// restore-from-disk trivial, since a cell is never anything but its index.
//
// Nothing is kept loaded: closing the board lets the host's Loader destroy
// this instance, so a closed game costs the shell nothing. The board in
// progress lives on disk instead, written after every move and flushed on
// close, and the next open reads it back.
Item {
id: root
property bool opened: false
readonly property string selfId: "jankeesvw.omasweeper"
// Injected by the shell host after the Loader resolves. Used to keep the
// host's open-flag honest on close(), and to self-restore if the host's
// panel Instantiator rebuild destroys a visibly-open instance.
property var shell: null
onShellChanged: {
if (!root.opened && root.shell && root.shell.openPanelIds
&& root.shell.openPanelIds[root.selfId] === true)
root.open("{}")
}
// ------------------------------------------------------------------ theme
//
// Shares the [menu] surface tokens so a theme that styles the menu styles
// this panel too. Everything on the board is derived from those tokens
// rather than pinned, so the tiles stay readable on a light theme and a
// dark one without a second palette to maintain.
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: Color.menu.border
property var borderSpec: Border.surfaceSpec("menu", "border", border, Math.max(1, Style.space(2)))
property color accent: Color.accent
property color urgent: Color.urgent
readonly property int cornerRadius: Style.cornerRadius
property string fontFamily: Style.font.menuFamily
property int contentMargin: Style.spacing.panelPadding
function lum(c) { return 0.2126 * c.r + 0.7152 * c.g + 0.0722 * c.b }
function mix(a, b, t) {
return Qt.rgba(a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t, 1)
}
readonly property bool darkSurface: root.lum(root.background) < 0.5
// A covered cell is a filled character cell, an opened one is bare board.
// Everything else on the board is a hairline, so the contrast budget goes
// almost entirely into that one difference.
readonly property color boardBg: root.mix(root.background, root.foreground, 0.03)
readonly property color coverFill: root.mix(root.background, root.foreground, root.darkSurface ? 0.15 : 0.12)
readonly property color coverDot: root.mix(root.background, root.foreground, 0.32)
readonly property color hoverFill: root.mix(root.background, root.accent, 0.24)
readonly property color pressFill: root.mix(root.background, root.accent, 0.40)
readonly property color gridLine: root.mix(root.background, root.foreground, 0.17)
readonly property color dim: root.mix(root.background, root.foreground, 0.42)
readonly property color segFill: root.mix(root.background, root.foreground, 0.10)
readonly property color segValueFill: root.mix(root.background, root.foreground, 0.05)
// The eight numbers are ANSI, not the Windows palette: this is a board drawn
// in a terminal, and 3 should be the same red as an error line above it.
readonly property var numberColors: root.darkSurface
? ["#7aa2f7", "#9ece6a", "#f7768e", "#bb9af7", "#e0af68", "#7dcfff", "#c0caf5", "#565f89"]
: ["#2563eb", "#15803d", "#dc2626", "#7c3aed", "#b45309", "#0891b2", "#1f2937", "#6b7280"]
// ------------------------------------------------------------- difficulty
readonly property var levels: [
{ key: "beginner", name: "Beginner", cols: 9, rows: 9, mines: 10 },
{ key: "intermediate", name: "Intermediate", cols: 16, rows: 16, mines: 40 },
{ key: "expert", name: "Expert", cols: 30, rows: 16, mines: 99 }
]
property int level: 0
readonly property var levelSpec: root.levels[Math.max(0, Math.min(root.levels.length - 1, root.level))]
readonly property int cols: root.levelSpec.cols
readonly property int rows: root.levelSpec.rows
readonly property int mineCount: root.levelSpec.mines
readonly property int cellCount: root.cols * root.rows
// ------------------------------------------------------------- game model
property var mine: [] // booleans, one per cell
property var adj: [] // 0-8, mines touching this cell
property var shown: [] // booleans, revealed
property var flag: [] // booleans, flagged
property bool armed: false // mines are laid, i.e. the first click happened
property bool started: false
property bool dead: false
property bool won: false
property int boom: -1 // the mine that went off, drawn hot
property int shownCount: 0
property int flagsUsed: 0
property int seconds: 0
property var stats: root.blankStats()
readonly property bool finished: root.dead || root.won
readonly property int minesLeft: root.mineCount - root.flagsUsed
function blankStats() {
return {
beginner: { played: 0, won: 0, best: 0 },
intermediate: { played: 0, won: 0, best: 0 },
expert: { played: 0, won: 0, best: 0 }
}
}
function levelStats() {
var s = root.stats[root.levelSpec.key]
return s ? s : { played: 0, won: 0, best: 0 }
}
function timeText(s) {
var t = Math.max(0, Math.floor(s))
var m = Math.floor(t / 60)
var sec = t % 60
return m + ":" + (sec < 10 ? "0" : "") + sec
}
// Eight neighbours, minus whatever falls off an edge. Column arithmetic
// rather than a lookup table, so a difficulty switch needs no rebuild.
function neighbours(i) {
var out = []
var c = i % root.cols
var r = Math.floor(i / root.cols)
for (var dr = -1; dr <= 1; dr++) {
for (var dc = -1; dc <= 1; dc++) {
if (dr === 0 && dc === 0) continue
var nc = c + dc
var nr = r + dr
if (nc < 0 || nc >= root.cols || nr < 0 || nr >= root.rows) continue
out.push(nc + nr * root.cols)
}
}
return out
}
// --------------------------------------------------------------- new game
function newGame() {
var m = []
var a = []
var s = []
var f = []
for (var i = 0; i < root.cellCount; i++) {
m.push(false)
a.push(0)
s.push(false)
f.push(false)
}
root.mine = m
root.adj = a
root.shown = s
root.flag = f
root.armed = false
root.started = false
root.dead = false
root.won = false
root.boom = -1
root.shownCount = 0
root.flagsUsed = 0
root.seconds = 0
root.cursor = -1
root.cursorShown = false
root.hoverIndex = -1
root.pressIndex = -1
root.blip("deal")
root.save()
}
function setLevel(index) {
if (index < 0 || index >= root.levels.length) return
if (index === root.level) return
root.level = index
root.newGame()
}
// The first click is always safe, and so is everything around it: mines are
// laid after it, avoiding that cell and its neighbours, so the opening move
// always breaks the board open instead of ending the game.
function layMines(safe) {
var blocked = {}
blocked[safe] = true
var around = root.neighbours(safe)
var i
for (i = 0; i < around.length; i++) blocked[around[i]] = true
var pool = []
for (i = 0; i < root.cellCount; i++) if (!blocked[i]) pool.push(i)
// A tight board (9x9 with a lot of mines) can have fewer free cells than
// mines to place; then only the clicked cell itself stays safe.
if (pool.length < root.mineCount) {
pool = []
for (i = 0; i < root.cellCount; i++) if (i !== safe) pool.push(i)
}
// Fisher-Yates over the candidates, take the first mineCount of them.
for (i = pool.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1))
var tmp = pool[i]
pool[i] = pool[j]
pool[j] = tmp
}
var m = []
for (i = 0; i < root.cellCount; i++) m.push(false)
for (i = 0; i < root.mineCount && i < pool.length; i++) m[pool[i]] = true
root.mine = m
root.adj = root.countAdjacency(m)
root.armed = true
}
function countAdjacency(m) {
var a = []
for (var i = 0; i < root.cellCount; i++) {
if (m[i]) { a.push(0); continue }
var nb = root.neighbours(i)
var n = 0
for (var k = 0; k < nb.length; k++) if (m[nb[k]]) n++
a.push(n)
}
return a
}
// ------------------------------------------------------------------ play
function revealAt(i) {
if (root.finished) return
if (i < 0 || i >= root.cellCount) return
if (root.shown[i] === true || root.flag[i] === true) return
if (!root.armed) root.layMines(i)
root.started = true
if (root.mine[i] === true) { root.explode(i); return }
// Flood out of an empty cell, iteratively: a 30x16 expert board can open
// most of itself in one click and recursion here is a stack you do not
// need to spend. A flagged cell stops the flood, exactly as it should.
var s = root.shown.slice()
var count = root.shownCount
var stack = [i]
while (stack.length > 0) {
var c = stack.pop()
if (s[c] === true) continue
s[c] = true
count++
if (root.adj[c] !== 0) continue
var nb = root.neighbours(c)
for (var k = 0; k < nb.length; k++) {
var n = nb[k]
if (s[n] !== true && root.flag[n] !== true) stack.push(n)
}
}
root.shown = s
root.shownCount = count
if (!root.quietMoves) root.blip("open")
root.checkWin()
root.save()
}
function toggleFlag(i) {
if (root.finished) return
if (i < 0 || i >= root.cellCount) return
if (root.shown[i] === true) return
var f = root.flag.slice()
f[i] = f[i] !== true
root.flag = f
root.flagsUsed += f[i] ? 1 : -1
root.blip(f[i] ? "flag" : "unflag")
root.save()
}
// Clearing around a satisfied number: the move that makes the endgame fast
// and is also the only way to lose a game you had solved. Deliberately not
// guarded any further than the count.
function chordAt(i) {
if (root.finished) return
if (root.shown[i] !== true) return
var n = root.adj[i] || 0
if (n === 0) return
var nb = root.neighbours(i)
var flags = 0
var k
for (k = 0; k < nb.length; k++) if (root.flag[nb[k]] === true) flags++
if (flags !== n) return
// One chord is one sound, not one per cell it opens.
root.blip("chord")
root.quietMoves = true
for (k = 0; k < nb.length; k++) {
if (root.finished) break
root.revealAt(nb[k])
}
root.quietMoves = false
}
// Left-click does the obvious thing for where it landed: open a covered
// cell, clear around an opened number.
function primaryAt(i) {
if (i < 0 || i >= root.cellCount) return
if (root.shown[i] === true) root.chordAt(i)
else root.revealAt(i)
}
function explode(i) {
root.dead = true
root.boom = i
var s = root.shown.slice()
for (var c = 0; c < root.cellCount; c++) {
if (root.mine[c] === true && root.flag[c] !== true) s[c] = true
}
root.shown = s
root.blip("boom")
root.recordEnd(false)
root.save()
}
function checkWin() {
if (root.shownCount !== root.cellCount - root.mineCount) return
root.won = true
// Flag whatever is left: a cleared board should look cleared rather than
// leave the last few mines as covered cells the player has to trust.
var f = root.flag.slice()
var used = 0
for (var c = 0; c < root.cellCount; c++) {
f[c] = root.mine[c] === true
if (f[c]) used++
}
root.flag = f
root.flagsUsed = used
root.blip("win")
root.recordEnd(true)
}
property bool newBest: false
// The caret on the verdict line. 530ms is the blink a terminal uses.
property bool caretOn: true
Timer {
interval: 530
repeat: true
running: root.opened && root.finished
onTriggered: root.caretOn = !root.caretOn
}
function recordEnd(victory) {
var key = root.levelSpec.key
var all = JSON.parse(JSON.stringify(root.stats))
var s = all[key] ? all[key] : { played: 0, won: 0, best: 0 }
s.played = (s.played || 0) + 1
root.newBest = false
if (victory) {
s.won = (s.won || 0) + 1
if (!s.best || root.seconds < s.best) {
s.best = root.seconds
root.newBest = true
}
}
all[key] = s
root.stats = all
}
// ------------------------------------------------------------------ clock
// Runs while the board is open and the game is live. Closing the panel
// stops it: the game is paused on the shelf, not running in the background.
Timer {
interval: 1000
repeat: true
running: root.opened && root.started && !root.finished
onTriggered: {
root.seconds++
if (root.seconds % 10 === 0) root.save()
}
}
// ------------------------------------------------------------------ sound
//
// Square waves from sounds/, played through a tiny shell script that picks
// whichever player the machine has. Nothing is ever played while the window
// is closed, which keeps a shell restart (and the test IPC) silent.
property bool sound: true
property bool quietMoves: false // set while a chord fans out, so one chord is one sound
readonly property string assetDir: Qt.resolvedUrl(".").toString().replace(/^file:\/\//, "")
function blip(name) {
if (!root.sound || !root.opened) return
// sh <script> rather than the script itself, so a checkout that lost its
// executable bit still makes noise.
Quickshell.execDetached(["sh", root.assetDir + "bin/omasweeper-play",
root.assetDir + "sounds/" + name + ".wav"])
}
// ------------------------------------------------------------- persistence
readonly property string home: Quickshell.env("HOME")
readonly property string stateDir: root.home + "/.local/state/omasweeper"
readonly property string statePath: root.stateDir + "/state.json"
property bool stateLoaded: false
function save() {
if (!root.stateLoaded) return
saveTimer.restart()
}
// Debounced: a chord can fire several reveals in a row and each one would
// otherwise be its own atomic file write.
Timer {
id: saveTimer
interval: 400
repeat: false
onTriggered: root.writeState()
}
// The write itself. Closing the board calls this directly: the instance is
// about to be destroyed, and a pending debounce would be destroyed with it.
function writeState() {
if (!root.stateLoaded) return
saveTimer.stop()
var live = root.started && !root.finished
var payload = JSON.stringify({
version: 1,
level: root.levelSpec.key,
sound: root.sound,
stats: root.stats,
game: live ? {
armed: root.armed,
seconds: root.seconds,
mines: root.indicesWhere(root.mine),
shown: root.indicesWhere(root.shown),
flags: root.indicesWhere(root.flag)
} : null
}, null, 2) + "\n"
stateFile.setText(payload)
}
// Piles of booleans compress to the indices that are true, which is both
// smaller on disk and trivial to validate on the way back in.
function indicesWhere(arr) {
var out = []
for (var i = 0; i < root.cellCount; i++) if (arr[i] === true) out.push(i)
return out
}
function levelIndexOf(key) {
for (var i = 0; i < root.levels.length; i++) if (root.levels[i].key === key) return i
return -1
}
// A saved game is only restored if it still describes a board this
// difficulty could have produced: the right number of mines, every index in
// range and unique, and no opened cell sitting on a mine. Anything else
// deals a fresh board rather than half of one.
function restoreGame(g) {
if (!g || typeof g !== "object") return false
function claim(list, into) {
if (!Array.isArray(list)) return false
for (var i = 0; i < list.length; i++) {
var v = list[i]
if (typeof v !== "number" || v < 0 || v >= root.cellCount || into[v] === true) return false
into[v] = true
}
return true
}
var m = []
var s = []
var f = []
var i
for (i = 0; i < root.cellCount; i++) { m.push(false); s.push(false); f.push(false) }
if (!claim(g.mines, m) || !claim(g.shown, s) || !claim(g.flags, f)) return false
if (g.mines.length !== root.mineCount) return false
if (g.armed !== true && g.mines.length > 0) return false
for (i = 0; i < root.cellCount; i++) {
if (m[i] && s[i]) return false // an opened mine is a finished game
if (s[i] && f[i]) return false // and a flag on it is nonsense
}
if (g.shown.length >= root.cellCount - root.mineCount) return false
root.mine = m
root.shown = s
root.flag = f
root.adj = root.countAdjacency(m)
root.armed = true
root.started = true
root.dead = false
root.won = false
root.boom = -1
root.shownCount = g.shown.length
root.flagsUsed = g.flags.length
root.seconds = Math.max(0, Number(g.seconds) || 0)
return true
}
function applyState(raw) {
var st = null
try { st = JSON.parse(String(raw || "").trim()) } catch (e) {}
if (st && typeof st === "object") {
var idx = root.levelIndexOf(String(st.level || ""))
if (idx >= 0) root.level = idx
if (st.sound === false) root.sound = false
if (st.stats && typeof st.stats === "object") {
var all = root.blankStats()
for (var key in all) {
var s = st.stats[key]
if (!s || typeof s !== "object") continue
all[key] = {
played: Math.max(0, Number(s.played) || 0),
won: Math.max(0, Number(s.won) || 0),
best: Math.max(0, Number(s.best) || 0)
}
}
root.stats = all
}
if (root.restoreGame(st.game)) { root.stateLoaded = true; return }
}
root.stateLoaded = true
root.newGame()
}
FileView {
id: stateFile
path: root.statePath
atomicWrites: true
printErrors: false
onLoaded: root.applyState(text())
onLoadFailed: function(err) { root.applyState("") }
}
// Make sure the state dir exists, then (re)load the state file.
Process {
id: mkStateDir
command: ["mkdir", "-p", root.stateDir]
onExited: stateFile.reload()
}
Component.onCompleted: mkStateDir.running = true
// ------------------------------------------------------------- open/close
function open(payloadJson) {
root.opened = true
if (root.stateLoaded && root.cellCount !== root.mine.length) root.newGame()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function close() {
if (!root.opened) return
root.opened = false
root.hoverIndex = -1
root.pressIndex = -1
root.helpOpen = false
// A finished board is history the moment you look away: the next open
// deals rather than greeting you with the result you already read.
if (root.finished) root.newGame()
root.writeState()
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide(root.selfId)
}
function toggle() {
if (root.opened) root.close()
else root.open("{}")
}
// ------------------------------------------------------------------ layout
//
// The board is a character grid: square cells on hairlines, with a gutter of
// base-36 column and row labels around it, the way a hex dump numbers its
// rows. One number, the cell, sizes the whole thing, and the gutter is one
// cell wide on both axes so a label is always exactly one character.
// Everything in the surface that is not board. Measured rather than guessed,
// and none of it reads the surface size, so sizing the surface from it is
// safe.
readonly property int chromeHeight: header.height + Style.spacing.md * 4 + 2 + statusLine.height
// Taken from the window rather than the screen, so a tiled or resized
// window grows the board with it. The cap keeps a beginner board from
// becoming a wall of dinner plates on a wide monitor.
readonly property int cellSize: {
var availW = win.width - root.frameInsetW
var availH = win.height - root.frameInsetH
return Math.max(Style.space(9),
Math.floor(Math.min(availW / (root.cols + 1),
availH / (root.rows + 1),
Style.space(64))))
}
readonly property int gridW: root.cols * root.cellSize
readonly property int gridH: root.rows * root.cellSize
readonly property int gutter: root.cellSize
readonly property int boardW: root.gutter + root.gridW
readonly property int boardH: root.gutter + root.gridH
readonly property int cellFont: Math.max(8, Math.round(root.cellSize * 0.60))
readonly property int labelFont: Math.max(7, Math.round(root.cellSize * 0.42))
// Base 36, so the 30 columns of an expert board and the 16 rows still get
// one character each: 0-9 then a-t.
function label36(n) { return Number(n).toString(36) }
// Three digits, the way the counter on a cabinet game reads.
function pad3(n) {
var v = Math.max(0, Math.min(999, Math.floor(n)))
return (v < 10 ? "00" : v < 100 ? "0" : "") + v
}
// ---------------------------------------------------------------- keymap
//
// The single list of bindings: the `?` sheet renders it, and so does the
// hint line in the status bar. A binding that ships therefore cannot go
// missing from the help, which is the usual way help rots.
//
// The motions are vim's. hjkl was already here; the rest is what a hand
// that types hjkl reaches for next. H and L land where gg and G do, because
// the whole board is always on screen and there is nothing to scroll. They
// are bound anyway, since a finger that expects them expects them.
property bool helpOpen: false
readonly property string widestKey: {
var w = ""
for (var i = 0; i < root.keymap.length; i++)
for (var j = 0; j < root.keymap[i].keys.length; j++)
if (root.keymap[i].keys[j].key.length > w.length) w = root.keymap[i].keys[j].key
return w
}
readonly property var keymap: [
{
group: "motion",
keys: [
{ key: "h j k l", what: "left, down, up, right" },
{ key: "arrows", what: "the same, for the other hand" },
{ key: "0 ^", what: "first cell of the row" },
{ key: "$", what: "last cell of the row" },
{ key: "gg", what: "top of the column" },
{ key: "G", what: "bottom of the column" },
{ key: "H M L", what: "top, middle, bottom row" },
{ key: "C-d C-u", what: "half a board down, up" }
]
},
{
group: "play",
keys: [
{ key: "space", what: "open the cell" },
{ key: "enter", what: "open, or deal again when finished" },
{ key: "f", what: "flag or unflag" },
{ key: "n", what: "new game" }
]
},
{
group: "game",
keys: [
{ key: "1 2 3", what: "beginner, intermediate, expert" },
{ key: "m", what: "mute" },
{ key: "?", what: "these keys" },
{ key: "q esc", what: "close" }
]
}
]
// ------------------------------------------------------------- pointer state
property int hoverIndex: -1
property int pressIndex: -1
property int cursor: -1 // keyboard cursor, -1 until an arrow is used
property bool cursorShown: false
readonly property int activeIndex: root.cursorShown && root.cursor >= 0 ? root.cursor : root.hoverIndex
readonly property int activeCol: root.activeIndex >= 0 ? root.activeIndex % root.cols : -1
readonly property int activeRow: root.activeIndex >= 0 ? Math.floor(root.activeIndex / root.cols) : -1
// Every motion goes through here, so the first key pressed on a board with
// no cursor yet only places one, in the middle. There is nothing to move
// relative to before that, and landing in a corner because you reached for
// `k` is not a start.
function seedCursor() {
if (root.cursor >= 0) return true
root.cursor = Math.floor(root.rows / 2) * root.cols + Math.floor(root.cols / 2)
root.cursorShown = true
return false
}
// Clamped rather than wrapped: a board has edges, and vim's motions stop at
// them too.
function placeCursor(c, r) {
root.cursor = Math.max(0, Math.min(root.cols - 1, c))
+ Math.max(0, Math.min(root.rows - 1, r)) * root.cols
root.cursorShown = true
}
function moveCursor(dc, dr) {
if (!root.seedCursor()) return
root.placeCursor((root.cursor % root.cols) + dc,
Math.floor(root.cursor / root.cols) + dr)
}
// Column-preserving and row-preserving jumps, the two halves of 0/$/gg/G.
function jumpToCol(c) {
if (!root.seedCursor()) return
root.placeCursor(c, Math.floor(root.cursor / root.cols))
}
function jumpToRow(r) {
if (!root.seedCursor()) return
root.placeCursor(root.cursor % root.cols, r)
}
// -------------------------------------------------------------------- parts
// A tab in the header, drawn the way a tmux window list draws one: the
// selected entry is the colours inverted, not a box with a border.
component TermTab: Item {
id: tab
property string label: ""
property bool active: false
property string tip: ""
signal activated()
implicitWidth: tabText.implicitWidth + Style.spacing.md * 2
implicitHeight: tabText.implicitHeight + Style.spacing.xs * 2
Rectangle {
anchors.fill: parent
color: tab.active ? root.accent
: tabMouse.containsMouse ? root.segFill
: "transparent"
}
Text {
id: tabText
anchors.centerIn: parent
text: tab.label
color: tab.active ? root.background : root.foreground
opacity: tab.active || tabMouse.containsMouse ? 1 : 0.7
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
id: tabMouse
anchors.fill: parent
// Dead until the saved game is back, for the same reason handleKey and
// the grid are: every tab here picks a level, deals, or writes a
// setting, and the restore is about to overwrite all three.
enabled: root.stateLoaded
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tab.activated()
}
}
// A mine, drawn rather than typed. The asterisk is the right character for
// it, but no two fonts agree where its ink sits inside the line box, and a
// mark that floats high in a grid of digits is the sort of thing you cannot
// unsee. Three bars through one centre cannot be off centre.
component MineMark: Item {
id: mark
property color tint: "#000000"
Repeater {
model: 3
delegate: Rectangle {
required property int index
anchors.centerIn: parent
width: mark.width
height: Math.max(1, Math.round(mark.width * 0.22))
radius: height / 2
color: mark.tint
rotation: index * 60
}
}
}
// One reading on the status line: a label block and its value block, butted
// together with no gap, so the row reads as one bar rather than four boxes.
component StatusSeg: Row {
id: seg
property string label: ""
property string value: ""
property bool hot: false
spacing: 0
Rectangle {
width: segLabel.implicitWidth + Style.spacing.sm * 2
height: segLabel.implicitHeight + Style.spacing.xxs * 2
color: seg.hot ? root.accent : root.segFill
Text {
id: segLabel
anchors.centerIn: parent
text: seg.label
color: seg.hot ? root.background : root.foreground
opacity: seg.hot ? 1 : 0.75
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Rectangle {
width: segValue.implicitWidth + Style.spacing.sm * 2
height: segLabel.implicitHeight + Style.spacing.xxs * 2
color: root.segValueFill
Text {
id: segValue
anchors.centerIn: parent
text: seg.value
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}
// ------------------------------------------------------------------- keys
//
// One handler, called both by the window's Keys.onPressed and by the test
// IPC, so a scripted keyboard and a real one cannot drift apart. Returns
// whether the key was ours.
//
// `g` is the only prefix here, and it only ever leads to `gg`. It is
// cleared by every other key rather than by a timeout, the way vim clears a
// pending operator: g then j is a j, not a lost keystroke.
property bool pendingG: false
function handleKey(key, shift, ctrl) {
// Nothing is playable until the saved game is back. The restore replaces
// every cell, so a move made before it lands is a move undone, and save()
// refuses to write one anyway. Closing stays live: a state file that is
// slow to arrive must not lock you inside the window.
if (!root.stateLoaded && key !== Qt.Key_Escape && !(key === Qt.Key_Q && !ctrl))
return true
var afterG = root.pendingG
root.pendingG = false
// The help sheet is a page you dismiss, not a layer you play through:
// while it is up, every key closes it or does nothing at all.
if (root.helpOpen) {
if (key === Qt.Key_Question || key === Qt.Key_Escape || key === Qt.Key_Q
|| key === Qt.Key_Space || key === Qt.Key_Return || key === Qt.Key_Enter)
root.helpOpen = false
return true
}
if (key === Qt.Key_Question) {
root.helpOpen = true
} else if (key === Qt.Key_Escape || (key === Qt.Key_Q && !ctrl)) {
root.close()
} else if (key === Qt.Key_N && !ctrl) {
root.newGame()
} else if (key === Qt.Key_1) {
root.setLevel(0)
} else if (key === Qt.Key_2) {
root.setLevel(1)
} else if (key === Qt.Key_3) {
root.setLevel(2)
// ---- motion
} else if (key === Qt.Key_Left || (key === Qt.Key_H && !shift)) {
root.moveCursor(-1, 0)
} else if (key === Qt.Key_Right || (key === Qt.Key_L && !shift)) {
root.moveCursor(1, 0)
} else if (key === Qt.Key_Up || (key === Qt.Key_K && !shift)) {
root.moveCursor(0, -1)
} else if (key === Qt.Key_Down || (key === Qt.Key_J && !shift)) {
root.moveCursor(0, 1)
} else if (key === Qt.Key_0 || key === Qt.Key_AsciiCircum) {
root.jumpToCol(0)
} else if (key === Qt.Key_Dollar) {
root.jumpToCol(root.cols - 1)
} else if (key === Qt.Key_G) {
if (shift) root.jumpToRow(root.rows - 1) // G
else if (afterG) root.jumpToRow(0) // gg
else root.pendingG = true
} else if (key === Qt.Key_H && shift) {
root.jumpToRow(0)
} else if (key === Qt.Key_M && shift) {
root.jumpToRow(Math.floor((root.rows - 1) / 2))
} else if (key === Qt.Key_L && shift) {
root.jumpToRow(root.rows - 1)
} else if (key === Qt.Key_D && ctrl) {
root.moveCursor(0, Math.floor(root.rows / 2))
} else if (key === Qt.Key_U && ctrl) {
root.moveCursor(0, -Math.floor(root.rows / 2))
// ---- play
} else if (key === Qt.Key_Space || key === Qt.Key_Return || key === Qt.Key_Enter) {
if (root.finished) root.newGame()
else if (root.cursor >= 0) root.primaryAt(root.cursor)
else root.seedCursor()
} else if (key === Qt.Key_F && !ctrl) {
if (root.cursor >= 0) root.toggleFlag(root.cursor)
else root.seedCursor()
} else if (key === Qt.Key_M) {
root.sound = !root.sound
root.save()
if (root.sound) root.blip("flag")
} else {
return false
}
return true
}
// A key by the name you would call it: "h", "G", "gg" is two of these, "$",
// "C-d", "space", "?". Anything else is a miss the caller hears about.
function keyByName(name) {
var ctrl = false
var n = String(name)
if (n.length > 2 && n.slice(0, 2).toUpperCase() === "C-") {
ctrl = true
n = n.slice(2)
}
var named = {
"space": Qt.Key_Space, "enter": Qt.Key_Return, "return": Qt.Key_Return,
"esc": Qt.Key_Escape, "escape": Qt.Key_Escape,
"left": Qt.Key_Left, "right": Qt.Key_Right, "up": Qt.Key_Up, "down": Qt.Key_Down,
"?": Qt.Key_Question, "$": Qt.Key_Dollar, "^": Qt.Key_AsciiCircum
}
var lower = n.toLowerCase()
if (named[lower] !== undefined) return { key: named[lower], shift: false, ctrl: ctrl }
if (n.length !== 1) return null
if (n >= "0" && n <= "9") return { key: Qt.Key_0 + (n.charCodeAt(0) - 48), shift: false, ctrl: ctrl }
if (lower >= "a" && lower <= "z")
return { key: Qt.Key_A + (lower.charCodeAt(0) - 97), shift: n !== lower, ctrl: ctrl }
return null
}
// -------------------------------------------------------------------- test
// Lets the game be played without a hand on the mouse, which is the only way
// to exercise it in a headless run. The channel is this instance, so it only
// answers while the board is open -- a closed board is unloaded, and the
// handler goes with it:
// omarchy-shell shell summon jankeesvw.omasweeper
// omarchy-shell jankeesvw.omasweeper.test deal
// omarchy-shell jankeesvw.omasweeper.test play 4 4
// omarchy-shell jankeesvw.omasweeper.test board
// Every entry point goes through the same functions the pointer calls, so a
// scripted game and a played one cannot drift apart.
IpcHandler {
target: "jankeesvw.omasweeper.test"
function play(col: int, row: int): string {
if (col < 0 || col >= root.cols || row < 0 || row >= root.rows) return "off the board"
root.primaryAt(col + row * root.cols)
return root.summary()
}
function flag(col: int, row: int): string {
if (col < 0 || col >= root.cols || row < 0 || row >= root.rows) return "off the board"
root.toggleFlag(col + row * root.cols)
return root.summary()
}
function deal(): string {
root.newGame()
return root.summary()
}
function level(name: string): string {
var i = root.levelIndexOf(name)
if (i < 0) return "no level named " + name
root.setLevel(i)
return root.summary()
}
// The board as text: # covered, F flag, * mine, . opened and empty.
function board(): string {
var out = []
for (var r = 0; r < root.rows; r++) {
var line = ""
for (var c = 0; c < root.cols; c++) {
var i = c + r * root.cols
if (root.flag[i] === true) line += "F"