Repository navigation
Expand file tree
/
Copy pathKinetikNotes.cs
More file actions
2432 lines (2240 loc) · 129 KB
/
Copy pathKinetikNotes.cs
File metadata and controls
2432 lines (2240 loc) · 129 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
// Kinetik Notes: sticky notes on the desktop, in the style of Kinetik's widget, Kinetik Calendar and Kinetik Clock.
// Shows one note per Markdown file in a folder (e.g. an Obsidian to-do folder) and follows the folder live.
// Tasks ("- [ ] …") can be ticked off on the note, which writes the change back to the file.
// Per-note options live in the file's front matter, so Obsidian sees them too:
// color: pink · title: … · pinned: true · due: 2026-10-20 · tags: [web, marking]
// Copyright © 2026 Akila Sella Hennedige. GPL-3.0-or-later.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Windows.Forms;
using Microsoft.Win32;
[assembly: AssemblyTitle("Kinetik Notes")]
[assembly: AssemblyProduct("Kinetik Notes")]
[assembly: AssemblyCopyright("Copyright © 2026 Akila Sella Hennedige")]
[assembly: AssemblyVersion("2.0.0.0")]
static class Program
{
public const string Version = "2.0.0";
// --demo: made-up notes in a temporary folder, no settings read from or saved to this PC.
// "--theme Name" picks its theme (for screenshots).
public static bool Demo;
public static string DemoTheme;
[DllImport("user32.dll")] static extern bool SetProcessDPIAware();
[STAThread]
static void Main(string[] args)
{
Demo = args.Contains("--demo");
int ti = Array.IndexOf(args, "--theme");
if (ti >= 0 && ti + 1 < args.Length) DemoTheme = args[ti + 1];
bool created;
using (new Mutex(true, Demo ? @"Local\KinetikNotesDemo" : @"Local\KinetikNotes", out created))
{
if (!created) return; // already running
SetProcessDPIAware();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
ToolStripManager.Renderer = new DarkMenu();
// "--settings Page" opens the settings window on that page when it starts
int si = Array.IndexOf(args, "--settings");
Backup.KeyName = "KinetikNotes"; Backup.AppTitle = "Kinetik Notes";
if (!Demo) Backup.OfferRestore();
var app = new NotesForm(si < 0 ? null : si + 1 < args.Length ? args[si + 1] : "General");
using (new KinetikSuite.Shutdown("KinetikNotes" + (Demo ? "Demo" : ""), () => { app.Shutdown(); Application.Exit(); }))
Application.Run(app);
}
if (!Demo) Backup.AfterExit();
}
}
// ======================================================================= Demo
// Made-up notes for --demo, written fresh to a temporary folder each time, so the widget can be shown
// (and screenshotted) without reading anyone's real notes.
static class DemoData
{
static readonly string[][] Files =
{
new[] { "Groceries.md", "---\ncolor: green\npinned: true\n---\n# Groceries\n\n- [x] Oat milk\n- [x] Bananas\n- [ ] Sourdough\n- [ ] Coffee beans\n- [ ] Basil\n- [ ] Parmesan\n" },
new[] { "Website launch.md", "---\ncolor: blue\ndue: {due}\ntags: [web]\n---\n# Website launch\n\n- [x] Final copy from marketing\n- [x] Compress hero images\n- [ ] Test the contact form\n- [ ] Set up redirects\n- [ ] Announce on socials\n" },
new[] { "Weekend.md", "---\ncolor: pink\n---\n# Weekend\n\n- [ ] Farmers market, 8 am\n- [ ] Return library books\n- [x] Book the campsite\n- [ ] Call Grandma\n" },
new[] { "Reading list.md", "---\ncolor: purple\ntags: [books]\n---\n# Reading list\n\n- [x] The Left Hand of Darkness\n- [ ] Piranesi\n- [ ] A Psalm for the Wild-Built\n" },
new[] { "Ideas.md", "---\ncolor: yellow\n---\n# Ideas\n\nA tiny app that waters the plants **when it rains less than 2 mm** in a week.\n\n- [ ] Sketch the sensor box\n" },
new[] { "Gym.md", "---\ncolor: orange\n---\n# Gym\n\n- [x] Mon: legs\n- [x] Wed: push\n- [ ] Fri: pull\n- [ ] Sun: 5 km run\n" },
};
public static Settings Settings()
{
string dir = Path.Combine(Path.GetTempPath(), "KinetikNotesDemo");
try
{
if (Directory.Exists(dir)) foreach (var f in Directory.GetFiles(dir, "*.md")) File.Delete(f);
Directory.CreateDirectory(dir);
string due = DateTime.Today.AddDays(3).ToString("yyyy-MM-dd");
foreach (var f in Files) File.WriteAllText(Path.Combine(dir, f[0]), f[1].Replace("{due}", due));
}
catch { }
var s = new Settings { Folder = dir, Layer = 2, Columns = 3, X = 240, Y = 160, Hotkey = false, ThemeName = Program.DemoTheme ?? "Pastel paper", ShowTags = true };
s.Sanitize();
return s;
}
}
// ======================================================================= Settings
class Settings
{
const string KeyPath = @"Software\KinetikNotes";
public const string DefaultTemplate = "# New note\n\n- [ ] ";
// Placement
public int X = int.MinValue, Y = int.MinValue, Layer = 0; // Layer: 0 on the desktop, 1 normal, 2 always on top
public bool Locked, Snap = true;
// Look
public int Width = 260, Scale = 100, Columns = 1, Round = 10, Gap = 10, Opacity = 100;
public int MaxHeight = 0; // note length in pixels (at 100% DPI): a longer note scrolls inside itself; 0 = no limit
public int Style = 0; // legacy (v1.1): 0 pastel, 1 vivid, 2 dark; only read to seed ThemeName
public string ThemeName = ""; // a Notes.ThemeNames name; "Auto" matches Windows' light or dark mode
public int DefaultColour = -1; // -1 = a different colour for each note, otherwise an index into Notes.ColourNames
public string FontName = ""; // "" = Segoe UI
public bool ShowCount = true, ShowProgress = true, ShowDue = true, ShowTags = false;
// Which notes, and how
public int Sort = 0; // 0 file name, 1 title, 2 last changed, 3 due date, 4 most open tasks
public int MaxLines = 0; // 0 = no limit
public bool PinnedFirst = true, HideDone, HideFinishedNotes, Subfolders;
public string Folder = "", TagFilter = "", Hidden = "", Collapsed = ""; // Hidden/Collapsed: note keys joined by '|'
// Behaviour
public int DoubleClick = 0; // 0 open in Obsidian, 1 open in the default editor, 2 show in File Explorer
public bool Hotkey = true, OpenNew = true, AskTitle = true;
public string Template = DefaultTemplate;
static IEnumerable<FieldInfo> Fields
{
get
{
return typeof(Settings).GetFields(BindingFlags.Public | BindingFlags.Instance)
.Where(f => f.FieldType == typeof(int) || f.FieldType == typeof(bool) || f.FieldType == typeof(string));
}
}
// Set when the saved settings exist but couldn't be read. The app then runs on defaults, and Save does nothing,
// so those defaults can never overwrite the real settings. Restarting the app tries again.
public static bool Unreadable;
// Reads the settings, retrying a few times: the key can be briefly unreadable, e.g. while a backup is being
// restored into it or another program has it busy.
public static Settings Load()
{
if (Program.Demo) return DemoData.Settings();
Settings s = null;
for (int attempt = 0; attempt < 4; attempt++)
{
if (attempt > 0) Thread.Sleep(500);
bool ok;
s = TryLoad(out ok);
if (ok) { Unreadable = false; s.Opacity = CommonMenu.Migrate(s.Opacity); return s; }
}
Unreadable = true;
MessageBox.Show("Kinetik Notes couldn't read its saved settings, so it's showing the default look and layout for now.\n\n" +
"Your settings haven't been changed, and nothing will be saved over them while this is showing. " +
"Restart Kinetik Notes to try again" + (Backup.Exists ? ", or use Settings → General → Restore." : "."),
"Kinetik Notes", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return s;
}
// ok is false when the settings are there but couldn't be read (a missing key is fine: first run).
static Settings TryLoad(out bool ok)
{
ok = true;
var s = new Settings();
try
{
using (var k = Registry.CurrentUser.OpenSubKey(KeyPath))
if (k != null)
foreach (var f in Fields)
{
var v = k.GetValue(f.Name);
if (v == null) continue;
try
{
if (f.FieldType == typeof(string)) f.SetValue(s, v.ToString());
else if (f.FieldType == typeof(int)) f.SetValue(s, Convert.ToInt32(v));
else f.SetValue(s, Convert.ToInt32(v) != 0);
}
catch { ok = false; }
}
}
catch { ok = false; }
s.Sanitize();
KinetikSuite.SettingsValidation.Sanitize(s); return s;
}
public void Sanitize()
{
Func<int, int, int, int> cl = (v, lo, hi) => Math.Max(lo, Math.Min(hi, v));
Width = cl(Width, 160, 800); Scale = cl(Scale, 50, 250); Layer = cl(Layer, 0, 2); Columns = cl(Columns, 1, 6);
Round = cl(Round, 0, 30); Gap = cl(Gap, 0, 40); Opacity = cl(Opacity, 20, 100); Style = cl(Style, 0, 2);
DefaultColour = cl(DefaultColour, -1, Notes.ColourNames.Length - 1); Sort = cl(Sort, 0, 4); MaxLines = cl(MaxLines, 0, 99);
DoubleClick = cl(DoubleClick, 0, 2); MaxHeight = MaxHeight <= 0 ? 0 : cl(MaxHeight, 80, 4000);
if (Template == null) Template = DefaultTemplate;
if (!Notes.ThemeNames.Contains(ThemeName)) ThemeName = Style == 1 ? "Vivid paper" : Style == 2 ? "Navy" : "Pastel paper";
}
public void Save()
{
if (Program.Demo || Unreadable) return;
KinetikSuite.SettingsValidation.Sanitize(this);
if (KinetikSuite.RegistryFiles.CannotSave) return; // never save defaults over settings that couldn't be read
try
{
using (var k = Registry.CurrentUser.CreateSubKey(KeyPath))
foreach (var f in Fields)
{
var v = f.GetValue(this);
if (f.FieldType == typeof(bool)) k.SetValue(f.Name, (bool)v ? 1 : 0);
else if (f.FieldType == typeof(int)) k.SetValue(f.Name, (int)v);
else k.SetValue(f.Name, (string)v ?? "");
}
}
catch { }
}
// Lists of note keys kept in one string, e.g. the hidden and rolled-up notes.
public static bool InList(string list, string key) { return ("|" + (list ?? "") + "|").Contains("|" + key + "|"); }
public static string WithKey(string list, string key, bool on)
{
var l = (list ?? "").Split('|').Where(x => x != "" && x != key).ToList();
if (on) l.Add(key);
return string.Join("|", l);
}
}
static class Startup
{
const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run", Name = "Kinetik Notes";
public static bool IsOn
{
get
{
try { using (var k = Registry.CurrentUser.OpenSubKey(RunKey)) return k != null && k.GetValue(Name) != null; }
catch { return false; }
}
}
public static void Set(bool on)
{
try
{
using (var k = Registry.CurrentUser.CreateSubKey(RunKey))
if (on) k.SetValue(Name, "\"" + Application.ExecutablePath + "\"");
else k.DeleteValue(Name, false);
}
catch { }
}
}
// ======================================================================= Themes (same set as Kinetik Clock and Kinetik Calendar)
class Theme
{
public readonly string Name;
public readonly Color Bg, Text, Sub, Accent;
public readonly bool Light;
public Theme(string name, Color bg, Color text, Color sub, Color accent, bool light)
{
Name = name; Bg = bg; Text = text; Sub = sub; Accent = accent; Light = light;
}
static Color C(int r, int g, int b) { return Color.FromArgb(r, g, b); }
// The terminal themes (the same green, amber and yellow as Kinetik's dashboard and Kinetik Screen) draw in a
// monospace font unless a font is picked, and their divider lines glow in the theme's colour.
public bool Term { get { return IsTerm(Name); } }
public static bool IsTerm(string name) { return name == "Terminal" || name == "Amber terminal" || name == "Yellow terminal"; }
static string mono;
public static string Mono
{
get
{
if (mono == null)
{
mono = "Consolas";
try { using (var f = new Font("Cascadia Mono", 10f)) if (f.Name == "Cascadia Mono") mono = "Cascadia Mono"; } catch { }
}
return mono;
}
}
public static readonly Theme[] All =
{
new Theme("Midnight", C(22, 22, 26), C(242, 242, 246), C(168, 168, 180), C(240, 88, 88), false),
new Theme("Navy", C(22, 27, 40), C(236, 240, 248), C(138, 150, 176), C(77, 166, 255), false),
new Theme("Graphite", C(40, 40, 44), C(236, 236, 236), C(160, 160, 166), C(255, 159, 10), false),
new Theme("Nord", C(46, 52, 64), C(236, 239, 244), C(152, 162, 182), C(136, 192, 208), false),
new Theme("Dracula", C(40, 42, 54), C(248, 248, 242), C(150, 156, 192), C(255, 121, 198), false),
new Theme("Solarized", C(0, 43, 54), C(238, 232, 213), C(131, 148, 150), C(203, 75, 22), false),
new Theme("Forest", C(22, 33, 27), C(228, 240, 231), C(138, 166, 148), C(108, 201, 124), false),
new Theme("Ocean", C(12, 29, 45), C(228, 242, 250), C(128, 160, 186), C(56, 198, 232), false),
new Theme("Sunset", C(40, 23, 35), C(250, 236, 240), C(192, 150, 172), C(255, 138, 92), false),
new Theme("Mono", C(10, 10, 10), C(245, 245, 245), C(140, 140, 140), C(245, 245, 245), false),
new Theme("OLED black", C(0, 0, 0), C(242, 242, 245), C(136, 136, 146), C(76, 194, 255), false),
new Theme("Mocha", C(30, 30, 46), C(205, 214, 244), C(147, 153, 178), C(203, 166, 247), false),
new Theme("Terminal", C(3, 10, 5), C(90, 255, 120), C(46, 170, 80), C(205, 255, 215), false),
new Theme("Amber terminal", C(12, 7, 0), C(255, 182, 40), C(185, 118, 22), C(255, 232, 180), false),
new Theme("Yellow terminal", C(0, 0, 0), C(255, 226, 0), C(200, 178, 0), C(255, 255, 190), false),
new Theme("Light", C(246, 246, 248), C(24, 24, 28), C(96, 96, 106), C(230, 70, 70), true),
new Theme("Paper", C(250, 246, 236), C(52, 42, 32), C(132, 116, 96), C(204, 92, 52), true),
new Theme("Frost", C(236, 242, 250), C(20, 30, 48), C(92, 106, 132), C(40, 120, 230), true),
};
public static Theme Get(string name)
{
// "App" follows the theme and accent colour every Kinetik app shares.
if (name == "App") { var l = KinetikSuite.Look.Current; return new Theme("App", l.Card, l.Text, l.Sub, l.Accent, l.Light); }
if (name == "Auto")
{
bool light = false;
try
{
using (var k = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize"))
light = k != null && Convert.ToInt32(k.GetValue("AppsUseLightTheme", 0)) == 1;
}
catch { }
return Get(light ? "Light" : "Midnight");
}
foreach (var t in All) if (t.Name == name) return t;
return All[0];
}
}
// A theme tile for the settings window: little sticky notes drawn in that theme, like the pickers in the other Kinetik widgets.
class ThemeTile : Control
{
public static readonly Color Page = Color.FromArgb(20, 23, 32), Card = Color.FromArgb(34, 39, 54), CardHi = Color.FromArgb(44, 51, 70), Accent = Color.FromArgb(90, 160, 255);
public readonly string ThemeName;
bool selected, hot;
float sel;
readonly float k;
readonly System.Windows.Forms.Timer t = new System.Windows.Forms.Timer { Interval = 15 };
public ThemeTile(string name, bool isSelected, float scale)
{
ThemeName = name; selected = isSelected; sel = isSelected ? 1 : 0; k = scale;
SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint, true);
Size = new Size(I(110), I(88));
Margin = new Padding(0, 0, I(8), I(8));
Cursor = Cursors.Hand;
BackColor = Page;
t.Tick += (s, e) =>
{
float target = selected ? 1 : 0;
sel += (target - sel) * 0.3f;
if (Math.Abs(target - sel) < 0.02f) { sel = target; t.Stop(); }
Invalidate();
};
}
int I(float v) { return (int)Math.Round(v * k); }
public void SetSelected(bool v) { if (selected == v) return; selected = v; t.Start(); }
protected override void OnMouseEnter(EventArgs e) { hot = true; Invalidate(); base.OnMouseEnter(e); }
protected override void OnMouseLeave(EventArgs e) { hot = false; Invalidate(); base.OnMouseLeave(e); }
protected override void Dispose(bool disposing) { if (disposing) t.Dispose(); base.Dispose(disposing); }
protected override void OnPaint(PaintEventArgs e)
{
var g = e.Graphics;
g.Clear(BackColor);
g.SmoothingMode = SmoothingMode.AntiAlias;
var outer = new RectangleF(1, 1, Width - 3, Height - 3);
Draw.Fill(g, outer, I(10), hot ? CardHi : Card);
if (sel > 0) Draw.Stroke(g, outer, I(10), Color.FromArgb((int)(255 * sel), Accent), 2);
var inner = new RectangleF(I(8), I(8), Width - I(16), Height - I(36));
if (ThemeName == "Auto")
{
Mini(g, inner, "Midnight");
var st = g.Save();
g.SetClip(new RectangleF(inner.X + inner.Width / 2, inner.Y, inner.Width, inner.Height));
Mini(g, inner, "Light");
g.Restore(st);
}
else Mini(g, inner, ThemeName);
using (var f = new Font("Segoe UI", 9f))
TextRenderer.DrawText(g, ThemeName == "Auto" ? "Match Windows" : ThemeName == "App" ? "Match app theme" : ThemeName, f, new Rectangle(0, Height - I(27), Width, I(20)), selected ? Color.FromArgb(232, 236, 244) : Color.FromArgb(150, 158, 178),
TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
}
// Two overlapping mini notes (yellow and blue), so the tile shows how note colours come through.
void Mini(Graphics g, RectangleF r, string theme)
{
for (int i = 0; i < 2; i++)
{
Color card, band, ink, soft, accent;
Notes.Look(theme, i == 0 ? 0 : 4, out card, out band, out ink, out soft, out accent);
var nr = i == 0 ? new RectangleF(r.X, r.Y, r.Width * 0.62f, r.Height) : new RectangleF(r.X + r.Width * 0.42f, r.Y + I(8), r.Width * 0.58f, r.Height - I(8));
Draw.Fill(g, nr, I(5), card);
using (var p = Draw.Round(nr, I(5))) { var st = g.Save(); g.SetClip(p); using (var b = new SolidBrush(band)) g.FillRectangle(b, nr.X, nr.Y, nr.Width, I(10)); g.Restore(st); }
Draw.Fill(g, new RectangleF(nr.X + I(5), nr.Y + I(3.5f), nr.Width * 0.5f, I(3.5f)), I(1.5f), ink);
for (int j = 0; j < 2; j++)
{
float yy = nr.Y + I(15) + j * I(9);
Draw.Stroke(g, new RectangleF(nr.X + I(5), yy, I(5), I(5)), I(1.2f), accent, 1.2f * k);
Draw.Fill(g, new RectangleF(nr.X + I(13), yy + I(1), nr.Width * (0.55f - j * 0.15f), I(3)), I(1.5f), Color.FromArgb(170, soft));
}
}
}
}
// ======================================================================= Emoji
// GDI+ can't draw colour emoji (they come out as outlines or boxes), and neither can WPF on .NET Framework. So text
// that has any is laid out by DirectWrite and drawn by Direct2D with its colour-font option, into a bitmap that's cached
// until the text, font or colour changes. Plain text stays on GDI+. Direct2D and DirectWrite are called through their
// COM vtables (slot numbers from d2d1.h / dwrite.h), as there's no wrapper for them in .NET Framework.
static class Emoji
{
[DllImport("d2d1.dll")] static extern int D2D1CreateFactory(int type, ref Guid riid, IntPtr opts, out IntPtr factory);
[DllImport("dwrite.dll")] static extern int DWriteCreateFactory(int type, ref Guid riid, out IntPtr factory);
[DllImport("gdi32.dll")] static extern IntPtr CreateCompatibleDC(IntPtr hdc);
[DllImport("gdi32.dll")] static extern bool DeleteDC(IntPtr hdc);
[DllImport("gdi32.dll")] static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);
[DllImport("gdi32.dll")] static extern bool DeleteObject(IntPtr obj);
[DllImport("gdi32.dll")] static extern IntPtr CreateDIBSection(IntPtr hdc, ref BitmapInfoHeader bmi, uint usage, out IntPtr bits, IntPtr section, uint offset);
[StructLayout(LayoutKind.Sequential)] struct BitmapInfoHeader { public int Size, Width, Height; public short Planes, BitCount; public int Compression, SizeImage, XPels, YPels, ClrUsed, ClrImportant; }
[StructLayout(LayoutKind.Sequential)] struct RTProps { public int Type, Format, Alpha; public float DpiX, DpiY; public int Usage, MinLevel; }
[StructLayout(LayoutKind.Sequential)] struct ColorF { public float R, G, B, A; }
[StructLayout(LayoutKind.Sequential)] struct Point2F { public float X, Y; }
[StructLayout(LayoutKind.Sequential)] struct Rect { public int L, T, R, B; }
[StructLayout(LayoutKind.Sequential)] struct TextRange { public uint Start, Length; }
[StructLayout(LayoutKind.Sequential)] struct Trimming { public int Granularity; public uint Delimiter, DelimiterCount; }
[StructLayout(LayoutKind.Sequential)] struct TextMetrics { public float Left, Top, Width, WidthTrailing, Height, LayoutWidth, LayoutHeight; public uint MaxBidi, LineCount; }
delegate uint ReleaseFn(IntPtr self);
delegate int CreateDCTargetFn(IntPtr self, ref RTProps p, out IntPtr target);
delegate int CreateTextFormatFn(IntPtr self, [MarshalAs(UnmanagedType.LPWStr)] string family, IntPtr collection, int weight, int style, int stretch, float size, [MarshalAs(UnmanagedType.LPWStr)] string locale, out IntPtr format);
delegate int CreateTextLayoutFn(IntPtr self, [MarshalAs(UnmanagedType.LPWStr)] string text, uint length, IntPtr format, float maxW, float maxH, out IntPtr layout);
delegate int CreateEllipsisFn(IntPtr self, IntPtr format, out IntPtr sign);
delegate int SetIntFn(IntPtr self, int value);
delegate int SetTrimmingFn(IntPtr self, ref Trimming t, IntPtr sign);
delegate int SetStrikeFn(IntPtr self, int on, TextRange range);
delegate int GetMetricsFn(IntPtr self, out TextMetrics m);
delegate int BindDCFn(IntPtr self, IntPtr hdc, ref Rect r);
delegate int CreateBrushFn(IntPtr self, ref ColorF c, IntPtr props, out IntPtr brush);
delegate void BeginDrawFn(IntPtr self);
delegate void SetModeFn(IntPtr self, int mode);
delegate void ClearFn(IntPtr self, ref ColorF c);
delegate int EndDrawFn(IntPtr self, IntPtr tag1, IntPtr tag2);
delegate void DrawLayoutFn(IntPtr self, Point2F origin, IntPtr layout, IntPtr brush, int options);
static T Fn<T>(IntPtr obj, int slot) where T : class
{
var vtable = Marshal.ReadIntPtr(obj);
return Marshal.GetDelegateForFunctionPointer(Marshal.ReadIntPtr(vtable, slot * IntPtr.Size), typeof(T)) as T;
}
static void Release(IntPtr p) { if (p != IntPtr.Zero) Fn<ReleaseFn>(p, 2)(p); }
static IntPtr d2d, dwrite, target;
static bool tried, ok;
static readonly Dictionary<string, Bitmap> cache = new Dictionary<string, Bitmap>();
static bool Init()
{
if (tried) return ok;
tried = true;
try
{
var iidD2D = new Guid("06152247-6f50-465a-9245-118bfd3b6007");
var iidDW = new Guid("b859ee5a-d838-4b5b-a2e8-1adc7d93db48");
ok = D2D1CreateFactory(0, ref iidD2D, IntPtr.Zero, out d2d) >= 0 && DWriteCreateFactory(0, ref iidDW, out dwrite) >= 0;
}
catch { ok = false; }
return ok;
}
// True when the text has emoji and they can be drawn in colour here (Windows 8.1 or later).
public static bool Has(string s)
{
if (string.IsNullOrEmpty(s)) return false;
bool any = false;
foreach (var ch in s)
if (char.IsSurrogate(ch) || ch == '\uFE0F' || ch == '\u200D' || ch == '\u203C' || ch == '\u2049' ||
(ch >= '\u2300' && ch <= '\u23FF') || (ch >= '\u2600' && ch <= '\u27BF') || (ch >= '\u2B00' && ch <= '\u2BFF')) { any = true; break; }
return any && Init();
}
static float Px(Font f) { return f.Unit == GraphicsUnit.Pixel ? f.Size : f.SizeInPoints * 96f / 72f; }
static IntPtr Layout(string s, Font f, float maxW, bool oneLine)
{
IntPtr format;
if (Fn<CreateTextFormatFn>(dwrite, 15)(dwrite, f.FontFamily.Name, IntPtr.Zero, f.Bold ? 700 : 400, f.Italic ? 2 : 0, 5, Px(f), "en-us", out format) < 0) return IntPtr.Zero;
try
{
if (!oneLine) Fn<SetIntFn>(format, 5)(format, 3); // EMERGENCY_BREAK: a word longer than the line breaks mid-word (Windows 8.1+; ignored before)
if (oneLine)
{
Fn<SetIntFn>(format, 5)(format, 1); // SetWordWrapping(NO_WRAP)
IntPtr sign;
if (Fn<CreateEllipsisFn>(dwrite, 20)(dwrite, format, out sign) >= 0)
{
var tr = new Trimming { Granularity = 1 }; // character
Fn<SetTrimmingFn>(format, 9)(format, ref tr, sign);
Release(sign);
}
}
IntPtr layout;
if (Fn<CreateTextLayoutFn>(dwrite, 18)(dwrite, s, (uint)s.Length, format, Math.Max(1, maxW), 100000, out layout) < 0) return IntPtr.Zero;
if (f.Strikeout) Fn<SetStrikeFn>(layout, 37)(layout, 1, new TextRange { Start = 0, Length = (uint)s.Length });
return layout;
}
finally { Release(format); }
}
static float LayoutHeight(IntPtr layout)
{
TextMetrics m;
return Fn<GetMetricsFn>(layout, 60)(layout, out m) >= 0 ? m.Height : 0;
}
public static float Height(string s, Font f, float maxW)
{
var layout = Layout(s, f, maxW, false);
if (layout == IntPtr.Zero) return f.Height;
try { return LayoutHeight(layout); } finally { Release(layout); }
}
static Bitmap Render(string s, Font f, Color col, float width, bool oneLine)
{
var layout = Layout(s, f, width, oneLine);
if (layout == IntPtr.Zero) return null;
IntPtr dc = IntPtr.Zero, dib = IntPtr.Zero, old = IntPtr.Zero, brush = IntPtr.Zero;
try
{
int w = Math.Max(1, (int)Math.Ceiling(width) + 2), h = Math.Max(1, (int)Math.Ceiling(LayoutHeight(layout)) + 2);
if (target == IntPtr.Zero)
{
// Software: it only draws small bitmaps into a DIB, and a hardware target would load the whole graphics driver
// (some 200 MB and a dozen threads) and copy every result back from the GPU.
var props = new RTProps { Type = 1, Format = 87, Alpha = 1, DpiX = 96, DpiY = 96 }; // SOFTWARE, B8G8R8A8, premultiplied
if (Fn<CreateDCTargetFn>(d2d, 16)(d2d, ref props, out target) < 0) { target = IntPtr.Zero; return null; }
}
var bi = new BitmapInfoHeader { Size = 40, Width = w, Height = -h, Planes = 1, BitCount = 32 }; // top-down
IntPtr bits;
dc = CreateCompatibleDC(IntPtr.Zero);
dib = CreateDIBSection(dc, ref bi, 0, out bits, IntPtr.Zero, 0);
if (dib == IntPtr.Zero) return null;
old = SelectObject(dc, dib);
var rc = new Rect { R = w, B = h };
if (Fn<BindDCFn>(target, 57)(target, dc, ref rc) < 0) return null;
var c = new ColorF { R = col.R / 255f, G = col.G / 255f, B = col.B / 255f, A = col.A / 255f };
if (Fn<CreateBrushFn>(target, 8)(target, ref c, IntPtr.Zero, out brush) < 0) return null;
Fn<BeginDrawFn>(target, 48)(target);
Fn<SetModeFn>(target, 34)(target, 2); // grayscale antialiasing: ClearType needs an opaque background
var clear = new ColorF();
Fn<ClearFn>(target, 47)(target, ref clear);
Fn<DrawLayoutFn>(target, 28)(target, new Point2F(), layout, brush, 4); // ENABLE_COLOR_FONT
if (Fn<EndDrawFn>(target, 49)(target, IntPtr.Zero, IntPtr.Zero) < 0) { Release(target); target = IntPtr.Zero; return null; }
var bmp = new Bitmap(w, h, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);
var data = bmp.LockBits(new Rectangle(0, 0, w, h), System.Drawing.Imaging.ImageLockMode.WriteOnly, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);
var buf = new byte[w * h * 4];
Marshal.Copy(bits, buf, 0, buf.Length);
for (int y = 0; y < h; y++) Marshal.Copy(buf, y * w * 4, data.Scan0 + y * data.Stride, w * 4);
bmp.UnlockBits(data);
return bmp;
}
finally
{
Release(brush);
if (old != IntPtr.Zero) SelectObject(dc, old);
if (dib != IntPtr.Zero) DeleteObject(dib);
if (dc != IntPtr.Zero) DeleteDC(dc);
Release(layout);
}
}
public static void Draw(Graphics g, string s, Font f, Color col, RectangleF r, bool oneLine)
{
var key = s + "\u0001" + f.FontFamily.Name + "|" + f.Size + "|" + (int)f.Style + "|" + col.ToArgb() + "|" + (int)r.Width + "|" + oneLine;
Bitmap bmp;
if (!cache.TryGetValue(key, out bmp))
{
if (cache.Count > 400) Clear();
bmp = Render(s, f, col, r.Width, oneLine);
cache[key] = bmp;
}
if (bmp == null) { using (var b = new SolidBrush(col)) g.DrawString(s, f, b, r); return; } // Direct2D failed: plain text
// drawn 1:1 at whole pixels so the text stays sharp
var st = g.Save();
g.SetClip(new RectangleF(r.X, r.Y, r.Width + 2, Math.Max(r.Height, bmp.Height)), CombineMode.Intersect);
g.DrawImageUnscaled(bmp, (int)Math.Round(r.X), (int)Math.Round(r.Y));
g.Restore(st);
}
public static void Clear()
{
foreach (var b in cache.Values) if (b != null) b.Dispose();
cache.Clear();
}
}
// ======================================================================= Drawing helpers and the dark menu
static class Draw
{
public static GraphicsPath Round(RectangleF r, float rad)
{
var p = new GraphicsPath();
float d = Math.Min(rad * 2, Math.Min(r.Width, r.Height));
if (d <= 0.5f) { p.AddRectangle(r); return p; }
p.AddArc(r.X, r.Y, d, d, 180, 90);
p.AddArc(r.Right - d, r.Y, d, d, 270, 90);
p.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
p.AddArc(r.X, r.Bottom - d, d, d, 90, 90);
p.CloseFigure();
return p;
}
public static void Fill(Graphics g, RectangleF r, float rad, Color col)
{
using (var path = Round(r, rad)) using (var b = new SolidBrush(col)) g.FillPath(b, path);
}
public static void Stroke(Graphics g, RectangleF r, float rad, Color col, float width)
{
using (var path = Round(r, rad)) using (var p = new Pen(col, width)) g.DrawPath(p, path);
}
public static void Chevron(Graphics g, RectangleF r, bool up, Color col, float width)
{
float cx = r.X + r.Width / 2f, cy = r.Y + r.Height / 2f, w = r.Width * 0.28f, h = r.Height * 0.16f;
using (var p = new Pen(col, width) { StartCap = LineCap.Round, EndCap = LineCap.Round, LineJoin = LineJoin.Round })
if (up) g.DrawLines(p, new[] { new PointF(cx - w, cy + h), new PointF(cx, cy - h), new PointF(cx + w, cy + h) });
else g.DrawLines(p, new[] { new PointF(cx - w, cy - h), new PointF(cx, cy + h), new PointF(cx + w, cy - h) });
}
static string[] families;
public static string[] Families
{
get
{
if (families == null)
using (var fonts = new InstalledFontCollection())
families = fonts.Families.Select(f => f.Name).ToArray();
return families;
}
}
public static string DefaultFamily { get { return Families.Contains("Segoe UI Variable Text") ? "Segoe UI Variable Text" : "Segoe UI"; } }
}
class DarkColors : ProfessionalColorTable
{
static Color Bg { get { return KinetikSuite.Look.Current.Card; } }
static Color Hi { get { return KinetikSuite.Look.Current.Hover; } }
static Color Edge { get { return KinetikSuite.Look.Current.Border; } }
public override Color ToolStripDropDownBackground { get { return Bg; } }
public override Color ImageMarginGradientBegin { get { return Bg; } }
public override Color ImageMarginGradientMiddle { get { return Bg; } }
public override Color ImageMarginGradientEnd { get { return Bg; } }
public override Color MenuBorder { get { return Edge; } }
public override Color MenuItemBorder { get { return Hi; } }
public override Color MenuItemSelected { get { return Hi; } }
public override Color MenuItemSelectedGradientBegin { get { return Hi; } }
public override Color MenuItemSelectedGradientEnd { get { return Hi; } }
public override Color MenuItemPressedGradientBegin { get { return Hi; } }
public override Color MenuItemPressedGradientEnd { get { return Hi; } }
public override Color SeparatorDark { get { return Edge; } }
public override Color SeparatorLight { get { return Edge; } }
public override Color CheckBackground { get { return KinetikSuite.Look.Current.Hover; } }
public override Color CheckSelectedBackground { get { return KinetikSuite.Look.Current.Hover; } }
public override Color CheckPressedBackground { get { return KinetikSuite.Look.Current.Hover; } }
}
class DarkMenu : ToolStripProfessionalRenderer
{
public DarkMenu() : base(new DarkColors()) { RoundedEdges = false; }
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
{
e.TextColor = e.Item.Enabled ? KinetikSuite.Look.Current.Text : KinetikSuite.Look.Current.Sub;
base.OnRenderItemText(e);
}
protected override void OnRenderArrow(ToolStripArrowRenderEventArgs e)
{
e.ArrowColor = KinetikSuite.Look.Current.Sub;
base.OnRenderArrow(e);
}
}
// ======================================================================= Reading and writing the Markdown notes
class NoteLine
{
public int Kind; // 0 text, 1 heading, 2 bullet, 3 task, 4 blank gap
public string Text;
public bool Done;
public int Indent, Src; // Src = the line's index in the file, for ticking a task
}
class Note
{
public string Path, Key, Title, Revision;
public int Colour = -1; // index into Notes.ColourNames, -1 = not set in the file
public bool Pinned;
public int Tasks, DoneTasks;
public DateTime? Due;
public DateTime Modified;
public readonly HashSet<string> Tags = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public readonly List<NoteLine> Lines = new List<NoteLine>();
}
static class Notes
{
public static readonly string[] ColourNames = { "yellow", "orange", "pink", "purple", "blue", "green" };
// The two paper looks colour the whole note; every other theme (the same set as Kinetik's other widgets)
// uses its own background and text, and tints the title band with the note's colour.
public static readonly string[] PaperThemes = { "Pastel paper", "Vivid paper" };
public static IEnumerable<string> ThemeNames { get { return PaperThemes.Concat(new[] { "Auto", "App" }).Concat(Theme.All.Select(t => t.Name)); } }
static readonly Color[] pastel = { Color.FromArgb(255, 241, 150), Color.FromArgb(255, 207, 150), Color.FromArgb(255, 190, 210), Color.FromArgb(218, 200, 255), Color.FromArgb(185, 224, 255), Color.FromArgb(196, 240, 188) };
static readonly Color[] vivid = { Color.FromArgb(255, 214, 10), Color.FromArgb(255, 149, 0), Color.FromArgb(255, 105, 150), Color.FromArgb(170, 128, 255), Color.FromArgb(64, 168, 255), Color.FromArgb(70, 200, 110) };
static readonly Color[] deep = { Color.FromArgb(200, 150, 0), Color.FromArgb(220, 110, 20), Color.FromArgb(215, 60, 120), Color.FromArgb(130, 80, 220), Color.FromArgb(30, 120, 220), Color.FromArgb(40, 150, 80) };
public static Color Swatch(int i) { return pastel[i]; }
public static bool IsPaper(string theme) { return PaperThemes.Contains(theme); }
// Card, title band, text, faded text and accent (tick boxes, progress) colours for a note in a theme.
public static void Look(string theme, int colour, out Color card, out Color band, out Color ink, out Color soft, out Color accent)
{
if (IsPaper(theme))
{
bool v = theme == "Vivid paper";
card = v ? vivid[colour] : pastel[colour]; band = Color.FromArgb(24, 0, 0, 0);
ink = Color.FromArgb(40, 36, 24); soft = v ? Color.FromArgb(80, 70, 45) : Color.FromArgb(125, 110, 80);
accent = Color.FromArgb(150, ink);
return;
}
var t = Theme.Get(theme);
accent = t.Light ? deep[colour] : vivid[colour];
card = t.Bg; band = Color.FromArgb(t.Light ? 60 : 75, accent); ink = t.Text; soft = t.Sub;
}
static readonly Regex TaskRx = new Regex(@"^([-*+]\s+\[)([ xX])(\]\s?)(.*)$");
static readonly Regex TaskLineRx = new Regex(@"^(\s*[-*+]\s+\[)([ xX])(\].*)$");
static readonly Regex BulletRx = new Regex(@"^([-*+]|\d+[.)])\s+(.*)$");
static readonly Regex WikiRx = new Regex(@"\[\[([^\]|]+)\|?([^\]]*)\]\]");
static readonly Regex LinkRx = new Regex(@"\[([^\]]+)\]\([^)]*\)");
static readonly Regex HeadingRx = new Regex(@"^#{1,6}\s");
static readonly Regex TagRx = new Regex(@"(?<![\w&/])#([A-Za-z][\w/-]*)");
sealed class CachedNote { public long Stamp, Length; public Note Value; }
static readonly object cacheGate = new object();
static readonly Dictionary<string, CachedNote> cache = new Dictionary<string, CachedNote>(StringComparer.OrdinalIgnoreCase);
static DateTime verifiedAt;
public static void Invalidate(string path) { lock (cacheGate) { if (path == null) cache.Clear(); else cache.Remove(path); } }
static Note ReadCached(string path)
{
var info = new FileInfo(path);
lock (cacheGate)
{
CachedNote entry;
if (cache.TryGetValue(path, out entry) && entry.Stamp == info.LastWriteTimeUtc.Ticks && entry.Length == info.Length) return entry.Value;
}
var note = Read(path);
lock (cacheGate) cache[path] = new CachedNote { Stamp = info.LastWriteTimeUtc.Ticks, Length = info.Length, Value = note };
return note;
}
public static List<Note> Load(string folder, bool subfolders)
{
var list = new List<Note>();
lock (cacheGate) if ((DateTime.UtcNow - verifiedAt).TotalSeconds >= 60) { cache.Clear(); verifiedAt = DateTime.UtcNow; }
var files = KinetikSuite.Markdown.Files(folder, subfolders)
.Where(p => !p.Contains("\\.obsidian\\") && !p.Contains("\\.trash\\"))
.Where(p => !p.StartsWith(TemplatesDir(folder) + "\\", StringComparison.OrdinalIgnoreCase)) // templates aren't notes
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase);
int i = 0;
foreach (var path in files)
{
try
{
if (new FileInfo(path).Length > 256 * 1024) continue;
var n = ReadCached(path);
n.Key = path.Substring(folder.TrimEnd('\\').Length).TrimStart('\\').ToLowerInvariant();
if (n.Colour < 0) n.Colour = -2 - (i % ColourNames.Length); // -2… = picked by position, kept apart from "set in the file"
i++;
list.Add(n);
}
catch { }
}
return list;
}
static string[] ReadLines(string path)
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
using (var sr = new StreamReader(fs, Encoding.UTF8, true))
return sr.ReadToEnd().Split('\n');
}
static void AddTags(Note n, string s)
{
foreach (var t in s.Trim('[', ']').Split(',', ' ')) { var v = t.Trim().Trim('"', '\'').TrimStart('#'); if (v != "") n.Tags.Add(v); }
}
static Note Read(string path)
{
var n = new Note { Path = path, Title = Path.GetFileNameWithoutExtension(path), Modified = File.GetLastWriteTime(path) };
var document = KinetikSuite.TextDocument.Read(path);
n.Revision = document.Revision;
var lines = document.Text.Split('\n');
int i = 0;
bool titled = false, inCode = false, inTags = false; char fence = '\0'; int fenceLength = 0;
if (lines.Length > 0 && lines[0].Trim() == "---")
{
for (i = 1; i < lines.Length && lines[i].Trim() != "---"; i++)
{
var l = lines[i];
if (inTags && l.TrimStart().StartsWith("- ")) { AddTags(n, l.TrimStart().Substring(2)); continue; }
inTags = false;
int colon = l.IndexOf(':');
if (colon <= 0) continue;
var key = l.Substring(0, colon).Trim().ToLowerInvariant();
var val = l.Substring(colon + 1).Trim().Trim('"', '\'');
if (key == "color" || key == "colour") n.Colour = Array.IndexOf(ColourNames, val.ToLowerInvariant());
else if (key == "title" && val != "") { n.Title = val; titled = true; }
else if (key == "pinned") n.Pinned = val.ToLowerInvariant() == "true";
else if (key == "due") { DateTime d; if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.None, out d) || DateTime.TryParse(val, out d)) n.Due = d.Date; }
else if (key == "tags" || key == "tag") { if (val == "") inTags = true; else AddTags(n, val); }
}
i++;
}
for (; i < lines.Length; i++)
{
var raw = lines[i].TrimEnd('\r');
var t = raw.TrimStart();
if (KinetikSuite.Markdown.Fence(t, ref fence, ref fenceLength)) { inCode = fence != '\0'; continue; }
if (t == "")
{
if (n.Lines.Count > 0 && n.Lines[n.Lines.Count - 1].Kind != 4) n.Lines.Add(new NoteLine { Kind = 4 });
continue;
}
if (!inCode) foreach (Match tm in TagRx.Matches(t)) n.Tags.Add(tm.Groups[1].Value);
int spaces = 0;
foreach (var ch in raw) { if (ch == ' ') spaces++; else if (ch == '\t') spaces += 4; else break; }
var line = new NoteLine { Src = i, Indent = Math.Min(4, spaces / 2) };
Match m;
if (inCode) { line.Kind = 0; line.Text = t; }
else if ((m = TaskRx.Match(t)).Success)
{
line.Kind = 3; line.Done = m.Groups[2].Value != " "; line.Text = Clean(m.Groups[4].Value);
n.Tasks++; if (line.Done) n.DoneTasks++;
}
else if (HeadingRx.IsMatch(t))
{
var h = Clean(t.TrimStart('#').Trim());
if (!titled && n.Lines.Count == 0 && t.StartsWith("# ")) { n.Title = h; titled = true; continue; }
line.Kind = 1; line.Text = h; line.Indent = 0;
}
else if ((m = BulletRx.Match(t)).Success) { line.Kind = 2; line.Text = Clean(m.Groups[2].Value); }
else { line.Kind = 0; line.Text = Clean(t); }
n.Lines.Add(line);
}
while (n.Lines.Count > 0 && n.Lines[n.Lines.Count - 1].Kind == 4) n.Lines.RemoveAt(n.Lines.Count - 1);
return n;
}
// Strips the Markdown that would look like clutter on a note: links, bold, code ticks, highlights.
static string Clean(string s)
{
s = WikiRx.Replace(s, m => m.Groups[2].Value != "" ? m.Groups[2].Value : m.Groups[1].Value);
s = LinkRx.Replace(s, "$1");
return s.Replace("**", "").Replace("__", "").Replace("`", "").Replace("~~", "").Replace("==", "");
}
// ---- writing: every change keeps the file's encoding (BOM or not) and line endings
public static bool Toggle(string path, int line, string revision)
{
try
{
if (string.IsNullOrEmpty(revision)) throw new IOException("Refresh the note before editing it.");
KinetikSuite.SafeEdit.Update(path, revision, doc =>
{
var lines = doc.Text.Split('\n');
if (line < 0 || line >= lines.Length) throw new IOException("The task moved. Refresh the note.");
var m = TaskLineRx.Match(lines[line]);
if (!m.Success) throw new IOException("The task changed. Refresh the note.");
lines[line] = m.Groups[1].Value + (m.Groups[2].Value == " " ? "x" : " ") + m.Groups[3].Value;
return string.Join("\n", lines);
});
return true;
}
catch (Exception ex) { MessageBox.Show(ex.Message, "Kinetik Notes", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; }
}
// Sets (or, with null, removes) one key in the note's front matter, adding the front matter if it has none.
public static bool SetMeta(string path, string revision, string key, string value)
{
try
{
KinetikSuite.SafeEdit.Update(path, revision, doc =>
{
var lines = doc.Text.Split('\n').ToList();
string cr = lines.Count > 0 && lines[0].EndsWith("\r") ? "\r" : "";
int end = -1;
if (lines.Count > 0 && lines[0].Trim() == "---")
for (int i = 1; i < lines.Count; i++) if (lines[i].Trim() == "---") { end = i; break; }
if (end < 0)
{
if (value == null) return doc.Text;
lines.InsertRange(0, new[] { "---" + cr, key + ": " + value + cr, "---" + cr });
}
else
{
int at = -1;
for (int i = 1; i < end; i++)
{
var l = lines[i]; int colon = l.IndexOf(':');
if (colon > 0 && l.Substring(0, colon).Trim().ToLowerInvariant() == key) { at = i; break; }
}
if (at >= 0) { if (value == null) lines.RemoveAt(at); else lines[at] = key + ": " + value + cr; }
else if (value != null) lines.Insert(end, key + ": " + value + cr);
// an empty front matter block is just clutter
if (lines.Count > 1 && lines[0].Trim() == "---" && lines[1].Trim() == "---") { lines.RemoveRange(0, 2); if (lines.Count > 0 && lines[0].Trim() == "") lines.RemoveAt(0); }
}
return string.Join("\n", lines);
});
return true;
}
catch (Exception ex) { MessageBox.Show(ex.Message, "Kinetik Notes", MessageBoxButtons.OK, MessageBoxIcon.Information); return false; }
}
// Opens a note in Obsidian when it's inside a vault, otherwise in the default Markdown editor.
public static void Open(string path, int how)
{
try
{
if (how == 2) { Process.Start("explorer.exe", "/select,\"" + path + "\""); return; }
var dir = Path.GetDirectoryName(path);
while (how == 0 && dir != null && !Directory.Exists(Path.Combine(dir, ".obsidian"))) dir = Path.GetDirectoryName(dir);
if (how == 0 && dir != null)
{
var rel = path.Substring(dir.Length).TrimStart('\\').Replace('\\', '/');
if (rel.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) rel = rel.Substring(0, rel.Length - 3);
Process.Start("obsidian://open?vault=" + Uri.EscapeDataString(Path.GetFileName(dir)) + "&file=" + Uri.EscapeDataString(rel));
}
else Process.Start(path);
}
catch { try { Process.Start(path); } catch { } }
}
// ---- templates: Markdown files in a "Templates" folder inside the notes folder, so they can be edited in Obsidian
public const string TemplatesFolderName = "Templates";
public static string TemplatesDir(string folder) { return Path.Combine(folder, TemplatesFolderName); }
// The starter set, written the first time the folder is needed (or when restored from Settings).
static readonly string[][] defaultTemplates =
{
new[] { "To-do list", "---\ncolor: yellow\n---\n# {title}\n\n- [ ] \n- [ ] \n- [ ] \n" },
new[] { "Daily plan", "---\ncolor: orange\ndue: {date}\n---\n# {title}\n\n## Morning\n- [ ] \n\n## Afternoon\n- [ ] \n\n## Evening\n- [ ] \n\n## Notes\n" },
new[] { "Weekly plan", "---\ncolor: blue\ndue: {friday}\n---\n# {title}\n\nWeek of {weekstart}\n\n## Must do\n- [ ] \n- [ ] \n\n## Should do\n- [ ] \n\n## Could do\n- [ ] \n" },
new[] { "Weekly unit", "---\ncolor: blue\ndue: {friday}\ntags: [teaching]\n---\n# {title}\n\nWeek of {weekstart}\n\n## Prep\n- [ ] Update lesson slides\n- [ ] Prepare practical files\n\n## Class\n- [ ] Run practical\n- [ ] Check student progress\n\n## Admin\n- [ ] Mark submitted work\n- [ ] Record attendance\n- [ ] Reply to student questions\n" },
new[] { "Lesson plan", "---\ncolor: green\ndue: {date}\ntags: [teaching]\n---\n# {title}\n\n## Learning objectives\n- \n- \n\n## Activities\n- [ ] Intro / recap (10 min)\n- [ ] Demo (20 min)\n- [ ] Practical (60 min)\n- [ ] Wrap-up and questions (10 min)\n\n## Resources\n- \n" },
new[] { "Meeting notes", "---\ncolor: purple\n---\n# {title}\n\n{weekday} {date}, {time}\n\n## Attendees\n- \n\n## Agenda\n- \n\n## Actions\n- [ ] \n" },
new[] { "Shopping list", "---\ncolor: green\n---\n# {title}\n\n- [ ] \n- [ ] \n- [ ] \n" },
new[] { "Project", "---\ncolor: pink\ndue: {nextweek}\n---\n# {title}\n\n## Goal\n\n\n## Milestones\n- [ ] \n- [ ] \n- [ ] \n\n## Next steps\n- [ ] \n" },
new[] { "Reminder", "---\ncolor: pink\ndue: {tomorrow}\npinned: true\n---\n# {title}\n\n- [ ] \n" },
};
// Writes any missing starter templates; with overwrite, puts every one back to how it shipped.
public static void EnsureTemplates(string folder, bool overwrite)
{
var dir = TemplatesDir(folder);
bool fresh = !Directory.Exists(dir);
if (!fresh && !overwrite) return; // the user's folder: leave their edits and deletions alone
Directory.CreateDirectory(dir);
foreach (var t in defaultTemplates)
{
var p = Path.Combine(dir, t[0] + ".md");
if (overwrite || !File.Exists(p)) File.WriteAllText(p, t[1], new UTF8Encoding(false));
}
}
public static List<string> Templates(string folder)
{
var dir = TemplatesDir(folder);
if (!Directory.Exists(dir)) return new List<string>();
return Directory.GetFiles(dir, "*.md").OrderBy(p => p, StringComparer.OrdinalIgnoreCase).ToList();
}
// Fills in a template's placeholders: {title}, {date}, {time}, {weekday}, {tomorrow}, {weekstart} (Monday),
// {friday} (this week's), {nextweek}. Dates are yyyy-MM-dd so Obsidian and the due badge both read them.
public static string Fill(string text, string title)
{
var today = DateTime.Today;
var monday = today.AddDays(-(((int)today.DayOfWeek + 6) % 7));
Func<DateTime, string> d = x => x.ToString("yyyy-MM-dd");
return text.Replace("\r\n", "\n")
.Replace("{title}", title).Replace("{date}", d(today)).Replace("{time}", DateTime.Now.ToString("HH:mm"))
.Replace("{weekday}", today.ToString("dddd")).Replace("{tomorrow}", d(today.AddDays(1)))
.Replace("{weekstart}", d(monday)).Replace("{friday}", d(monday.AddDays(4))).Replace("{nextweek}", d(today.AddDays(7)));
}
// Makes a note from a template file (null = the blank-note template in Settings), named after its title.
public static string NewNote(string folder, string templatePath, string blank, string title)
{
var name = new string(title.Select(ch => Path.GetInvalidFileNameChars().Contains(ch) ? '-' : ch).ToArray()).Trim().TrimEnd('.');
if (name == "") name = "New note";
int n = 1;
string path;
while (File.Exists(path = Path.Combine(folder, name + (n > 1 ? " " + n : "") + ".md"))) n++;
var text = templatePath != null ? File.ReadAllText(templatePath) : (blank ?? Settings.DefaultTemplate);
// the blank template predates {title}; give it the title if it still starts with the stock heading
if (templatePath == null && text.StartsWith("# New note")) text = "# {title}" + text.Substring("# New note".Length);