norcal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +58 -0
  4. data/bin/norcal +369 -0
  5. metadata +57 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be5696e7434a165a2ef025c8152ddd359795d5cf6e1a42d6dae6a32c2f636e90
4
+ data.tar.gz: fd6398b5697251b1c28c1c0b20c8fb3d99bc6b08980004612d37c94ea00e3469
5
+ SHA512:
6
+ metadata.gz: f8e7e3cf2c3e977ba03769fa6c215c18e2ff5fe13f7439b36ed18108f4db5d7ce6d12ef0cb9076ff88b70f923a30e2815b422d79a4da0de80a7de0d4a8192abb
7
+ data.tar.gz: d2f1258004cd2b3b3a0fbc0a501fd94ef6bd0b5df63145747c899d68916718ffa9fcdb181d3ea46b7355b4672df1c8056f053b5988d2c7632f81a71093c2e42a
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 baosen <chibaosen@gmail.com>
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # norcal
2
+
3
+ A local Norwegian desktop calendar.
4
+
5
+ ![norcal screenshot](screenshot.png)
6
+
7
+ ## Install
8
+
9
+ First, install the Tcl/Tk system libraries:
10
+
11
+ ```bash
12
+ # Debian/Ubuntu
13
+ sudo apt install libtk8.6 tk8.6-dev tcl8.6-dev
14
+
15
+ # Fedora
16
+ sudo dnf install tk-devel tcl-devel
17
+
18
+ # macOS
19
+ brew install tcl-tk
20
+ ```
21
+
22
+ ### From RubyGems
23
+
24
+ ```
25
+ gem install norcal
26
+ ```
27
+
28
+ ### From source
29
+
30
+ ```bash
31
+ git clone https://github.com/baosen/norcal.git
32
+ cd norcal
33
+ gem build norcal.gemspec
34
+ gem install norcal-1.0.0.gem
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ```
40
+ norcal # current year, dark mode
41
+ norcal 2026 # specific year
42
+ norcal --light # light mode
43
+ norcal --light 2026 # light mode, specific year
44
+ ```
45
+
46
+ ## Features
47
+
48
+ - 12-month grid (4x3) with Norwegian month and day names
49
+ - ISO week numbers, Monday-first weeks
50
+ - Sundays and public holidays in red, Saturdays in gray
51
+ - Easter-based movable holidays (Computus algorithm)
52
+ - Notable dates: royal birthdays, Samefolkets dag, Morsdag, Farsdag, solverv, sommertid, and more
53
+ - Today highlighted with yellow background
54
+ - Dark mode (default) and light mode, with toggle button
55
+
56
+ ## License
57
+
58
+ [ISC](LICENSE)
data/bin/norcal ADDED
@@ -0,0 +1,369 @@
1
+ #!/usr/bin/env ruby
2
+ # norcal - Norwegian desktop calendar
3
+
4
+ require 'date'
5
+ require 'tk'
6
+
7
+ # Ruby 4.0 froze all string literals by default, which breaks the tk gem's
8
+ # internal _toUTF8/_fromUTF8 helpers (they call force_encoding on the return
9
+ # value of the C-level converter). Wrapping those C methods to dup frozen
10
+ # return values is the least-invasive fix.
11
+ class TclTkIp
12
+ %i[__toUTF8 __fromUTF8].each do |m|
13
+ orig = :"#{m}_orig"
14
+ alias_method orig, m
15
+ define_method(m) { |*a| (r = send(orig, *a)).frozen? ? +r : r }
16
+ end
17
+ end
18
+
19
+ # -- Norwegian locale constants ------------------------------------------------
20
+
21
+ MONTHS_NO = %w[
22
+ Januar Februar Mars April Mai Juni
23
+ Juli August September Oktober November Desember
24
+ ].freeze
25
+
26
+ DAYS_NO = %w[Ma Ti On To Fr Lø Sø].freeze
27
+ MABBR_NO = %w[jan feb mar apr mai jun jul aug sep okt nov des].freeze
28
+
29
+ # -- Date helpers --------------------------------------------------------------
30
+
31
+ # Easter Sunday via the Anonymous Gregorian algorithm (Computus)
32
+ def easter(year)
33
+ a = year % 19
34
+ b, c = year.divmod(100)
35
+ d, e = b.divmod(4)
36
+ f = (b + 8) / 25
37
+ g = (b - f + 1) / 3
38
+ h = (19 * a + b - d - g + 15) % 30
39
+ i, k = c.divmod(4)
40
+ l = (32 + 2 * e + 2 * i - h - k) % 7
41
+ m = (a + 11 * h + 22 * l) / 451
42
+ month, day = (h + l - 7 * m + 114).divmod(31)
43
+ Date.new(year, month, day + 1)
44
+ end
45
+
46
+ # n-th occurrence of a weekday (cwday 1=Mon..7=Sun) in a given month
47
+ def nth_wday(year, month, wday, n)
48
+ first = Date.new(year, month, 1)
49
+ first + ((wday - first.cwday) % 7) + (n - 1) * 7
50
+ end
51
+
52
+ # Last occurrence of a weekday in a given month
53
+ def last_wday(year, month, wday)
54
+ last = Date.new(year, month, -1)
55
+ last - ((last.cwday - wday) % 7)
56
+ end
57
+
58
+ # -- Norwegian holidays --------------------------------------------------------
59
+
60
+ # Public holidays (røde dager) - official days off
61
+ def red_days(year)
62
+ e = easter(year)
63
+ {
64
+ Date.new(year, 1, 1) => "Nyttårsdag",
65
+ e - 3 => "Skjærtorsdag",
66
+ e - 2 => "Langfredag",
67
+ e => "1. påskedag",
68
+ e + 1 => "2. påskedag",
69
+ Date.new(year, 5, 1) => "Off. høgtidsdag",
70
+ e + 39 => "Kr. Himmelfartsdag",
71
+ Date.new(year, 5, 17) => "Grunnlovsdag",
72
+ e + 49 => "1. pinsedag",
73
+ e + 50 => "2. pinsedag",
74
+ Date.new(year, 12, 25) => "1. juledag",
75
+ Date.new(year, 12, 26) => "2. juledag",
76
+ }
77
+ end
78
+
79
+ # Notable dates: [date, description, show_date_in_red?]
80
+ def notable_dates(year)
81
+ e = easter(year)
82
+ morsdag = nth_wday(year, 2, 7, 2)
83
+ farsdag = nth_wday(year, 11, 7, 2)
84
+ sommer_start = last_wday(year, 3, 7)
85
+ sommer_slutt = last_wday(year, 10, 7)
86
+ fastelavn = e - 49
87
+ nov27 = Date.new(year, 11, 27)
88
+ advent1 = nov27 + ((7 - nov27.cwday) % 7)
89
+
90
+ [
91
+ [Date.new(year, 1, 1), "Nyttårsdag", true],
92
+ [Date.new(year, 1, 21), "Prinsesse Ingrid Alexandra, #{year - 2004} år", false],
93
+ [Date.new(year, 2, 6), "Samefolkets dag", false],
94
+ [morsdag, "Morsdag", false],
95
+ [Date.new(year, 2, 14), "St. Valentines dag", false],
96
+ [fastelavn, "Fastelavn", false],
97
+ [Date.new(year, 2, 21), "Kong Harald, #{year - 1937} år", false],
98
+ [Date.new(year, 3, 20), "Vårjevndøgn", false],
99
+ [sommer_start, "Sommertid start", false],
100
+ [e - 7, "Palmesøndag", true],
101
+ [e - 3, "Skjærtorsdag", true],
102
+ [e - 2, "Langfredag", true],
103
+ [e, "1. påskedag", true],
104
+ [e + 1, "2. påskedag", true],
105
+ [Date.new(year, 5, 1), "Off. høgtidsdag", true],
106
+ [Date.new(year, 5, 8), "Frigjøringsdag 1945", false],
107
+ [e + 39, "Kr. Himmelfartsdag", true],
108
+ [Date.new(year, 5, 17), "Grunnlovsdag 1814", true],
109
+ [e + 49, "1. pinsedag", true],
110
+ [e + 50, "2. pinsedag", true],
111
+ [Date.new(year, 6, 7), "Unionsoppløsning 1905", false],
112
+ [Date.new(year, 6, 21), "Sommersolverv", false],
113
+ [Date.new(year, 6, 23), "Jonsokaften", false],
114
+ [Date.new(year, 7, 4), "Dronning Sonja, #{year - 1937} år", false],
115
+ [Date.new(year, 7, 20), "Kronprins Haakon, #{year - 1973} år", false],
116
+ [Date.new(year, 7, 29), "Olsokdagen", false],
117
+ [Date.new(year, 8, 19), "Kronprinsesse Mette-Marit, #{year - 1973} år", false],
118
+ [Date.new(year, 9, 23), "Høstjevndøgn", false],
119
+ [sommer_slutt, "Sommertid slutt", false],
120
+ [Date.new(year, 10, 31), "Halloween", false],
121
+ [Date.new(year, 11, 1), "Allehelgensdag", false],
122
+ [farsdag, "Farsdag", false],
123
+ [advent1, "1. søndag i advent", false],
124
+ [Date.new(year, 12, 13), "Luciadagen", false],
125
+ [Date.new(year, 12, 21), "Vintersolverv", false],
126
+ [Date.new(year, 12, 25), "1. juledag", true],
127
+ [Date.new(year, 12, 26), "2. juledag", true],
128
+ ].sort_by { |d, _, _| d }
129
+ end
130
+
131
+ # -- GUI -----------------------------------------------------------------------
132
+
133
+ THEME = {
134
+ light: {
135
+ bg: '#ffffff', fg: '#000000', title_fg: '#333333',
136
+ red: '#cc0000', gray: '#999999', dkgray: '#444444',
137
+ border: '#777777', today_bg: '#ffed6f', desc_fg: '#333333',
138
+ sep: '#777777', toggle_label: 'dark',
139
+ },
140
+ dark: {
141
+ bg: '#1e1e1e', fg: '#d4d4d4', title_fg: '#cccccc',
142
+ red: '#ff6b6b', gray: '#777777', dkgray: '#999999',
143
+ border: '#555555', today_bg: '#4a4400', desc_fg: '#aaaaaa',
144
+ sep: '#555555', toggle_label: 'light',
145
+ },
146
+ }.freeze
147
+
148
+ # Parse arguments: optional --light flag and optional year (dark is default)
149
+ $dark = ARGV.delete('--light') ? false : true
150
+ $year = (ARGV[0] || Date.today.year).to_i
151
+
152
+ def theme
153
+ THEME[$dark ? :dark : :light]
154
+ end
155
+
156
+ root = TkRoot.new do
157
+ title "norcal"
158
+ end
159
+
160
+ # Fonts
161
+ ft_title = TkFont.new(family: 'Noto Serif', size: 22, weight: 'bold')
162
+ ft_mheader = TkFont.new(family: 'Noto Serif', size: 13, weight: 'bold')
163
+ ft_cal = TkFont.new(family: 'Noto Serif', size: 12)
164
+ ft_cal_bold = TkFont.new(family: 'Noto Serif', size: 12, weight: 'bold')
165
+ ft_note = TkFont.new(family: 'Noto Serif', size: 11)
166
+ ft_note_b = TkFont.new(family: 'Noto Serif', size: 11, weight: 'bold')
167
+ ft_note_d = TkFont.new(family: 'Noto Serif', size: 11)
168
+ ft_toggle = TkFont.new(family: 'Noto Serif', size: 9)
169
+
170
+ # -- Calendar content ----------------------------------------------------------
171
+
172
+ def render_month(parent, year, month, holidays, fonts)
173
+ ft_mheader, ft_cal, ft_cal_bold = fonts
174
+ t = theme
175
+
176
+ first = Date.new(year, month, 1)
177
+ last_day = Date.new(year, month, -1)
178
+ monday = first - (first.cwday - 1)
179
+ today = Date.today
180
+
181
+ outer = TkFrame.new(parent) {
182
+ borderwidth 1; relief 'solid'; background t[:bg]
183
+ highlightbackground t[:border]; highlightthickness 0
184
+ }
185
+
186
+ grid = TkFrame.new(outer) { background t[:bg]; padx 6; pady 4 }
187
+ grid.pack
188
+
189
+ # Month name header
190
+ TkLabel.new(grid) {
191
+ text MONTHS_NO[month - 1]; font ft_mheader; foreground t[:fg]; background t[:bg]
192
+ }.grid(row: 0, column: 0, columnspan: 8, pady: [0, 2])
193
+
194
+ # Day-name headers
195
+ TkLabel.new(grid) {
196
+ text 'Uke'; font ft_cal; foreground t[:dkgray]; background t[:bg]
197
+ }.grid(row: 1, column: 0, sticky: 'e', padx: [0, 4])
198
+
199
+ DAYS_NO.each_with_index do |d, i|
200
+ fg = case i
201
+ when 5 then t[:gray]
202
+ when 6 then t[:red]
203
+ else t[:fg]
204
+ end
205
+ TkLabel.new(grid) {
206
+ text d; font ft_cal_bold; foreground fg; background t[:bg]
207
+ }.grid(row: 1, column: i + 1, sticky: 'e')
208
+ end
209
+
210
+ # Week rows
211
+ row = 2
212
+ cur = monday
213
+ while cur <= last_day
214
+ TkLabel.new(grid) {
215
+ text cur.cweek.to_s; font ft_cal; foreground t[:dkgray]; background t[:bg]
216
+ }.grid(row: row, column: 0, sticky: 'e', padx: [0, 4])
217
+
218
+ 7.times do |i|
219
+ day = cur + i
220
+ if day.month == month && day.year == year
221
+ is_red = day.cwday == 7 || holidays.key?(day)
222
+ is_sat = day.cwday == 6 && !holidays.key?(day)
223
+ is_today = day == today
224
+ fg = if is_red then t[:red]
225
+ elsif is_sat then t[:gray]
226
+ else t[:fg]
227
+ end
228
+ TkLabel.new(grid) {
229
+ text day.day.to_s
230
+ font(is_today ? ft_cal_bold : ft_cal)
231
+ foreground fg
232
+ background(is_today ? t[:today_bg] : t[:bg])
233
+ }.grid(row: row, column: i + 1, sticky: 'e')
234
+ end
235
+ end
236
+ row += 1
237
+ cur += 7
238
+ end
239
+
240
+ # Pad empty rows so all months have the same height (max 6 week rows)
241
+ while row < 8
242
+ TkLabel.new(grid) { text ' '; font ft_cal; background t[:bg] }
243
+ .grid(row: row, column: 0)
244
+ row += 1
245
+ end
246
+
247
+ # Uniform column widths
248
+ (1..7).each { |c| grid.grid_columnconfigure(c, uniform: 'day', minsize: 22) }
249
+
250
+ outer
251
+ end
252
+
253
+ def render_notable(parent, year, fonts)
254
+ ft_note, ft_note_b, ft_note_d = fonts
255
+ t = theme
256
+ dates = notable_dates(year)
257
+
258
+ # Split into 3 roughly-equal columns
259
+ third = (dates.size / 3.0).ceil
260
+ cols = dates.each_slice(third).to_a
261
+
262
+ outer = TkFrame.new(parent) {
263
+ borderwidth 1; relief 'solid'; background t[:bg]
264
+ highlightbackground t[:border]; highlightthickness 0
265
+ }
266
+
267
+ inner = TkFrame.new(outer) { background t[:bg] }
268
+ inner.pack(padx: 5, pady: 5, fill: 'both')
269
+
270
+ cols.each_with_index do |entries, ci|
271
+ tw = TkText.new(inner) {
272
+ width 1; height(entries.size + 4)
273
+ font ft_note; borderwidth 0; highlightthickness 0
274
+ wrap 'word'; background t[:bg]; padx 3; pady 2
275
+ cursor ''; insertwidth 0
276
+ tabs '65'
277
+ }
278
+ tw.tag_configure('dt', foreground: t[:fg], font: ft_note_b)
279
+ tw.tag_configure('dtr', foreground: t[:red], font: ft_note_b)
280
+ tw.tag_configure('desc', foreground: t[:desc_fg], font: ft_note_d)
281
+
282
+ entries.each_with_index do |(date, desc, is_red), i|
283
+ dtag = is_red ? 'dtr' : 'dt'
284
+ date_str = "#{date.day}. #{MABBR_NO[date.month - 1]}"
285
+ tw.insert('end', date_str, dtag)
286
+ tw.insert('end', "\t")
287
+ tw.insert('end', desc, 'desc')
288
+ tw.insert('end', "\n") unless i == entries.size - 1
289
+ end
290
+
291
+ tw.state 'disabled'
292
+ tw.grid(row: 0, column: ci * 2, sticky: 'nsew', padx: [0, 2])
293
+ inner.grid_columnconfigure(ci * 2, weight: 1, uniform: 'notecol')
294
+ inner.grid_rowconfigure(0, weight: 1)
295
+
296
+ # vertical separator between columns (except after last)
297
+ if ci < cols.size - 1
298
+ sep = TkFrame.new(inner) { background t[:sep]; width 1 }
299
+ sep.grid(row: 0, column: ci * 2 + 1, sticky: 'ns', padx: 4)
300
+ end
301
+ end
302
+
303
+ outer
304
+ end
305
+
306
+ def build_calendar(parent, year, fonts)
307
+ ft_mheader, ft_cal, ft_cal_bold, ft_note, ft_note_b, ft_note_d = fonts
308
+ t = theme
309
+ holidays = red_days(year)
310
+
311
+ frame = TkFrame.new(parent) { background t[:bg] }
312
+
313
+ # Single grid for months and notable dates
314
+ grid_f = TkFrame.new(frame) { background t[:bg] }
315
+ grid_f.pack(padx: 10, pady: [2, 8])
316
+
317
+ 12.times do |idx|
318
+ m_frame = render_month(grid_f, year, idx + 1, holidays,
319
+ [ft_mheader, ft_cal, ft_cal_bold])
320
+ m_frame.grid(row: idx / 3, column: idx % 3, padx: 3, pady: 3, sticky: 'nsew')
321
+ end
322
+
323
+ # Notable dates in row 4, spanning all 3 month columns
324
+ note_f = render_notable(grid_f, year, [ft_note, ft_note_b, ft_note_d])
325
+ note_f.grid(row: 4, column: 0, columnspan: 3, padx: 3, pady: [4, 0], sticky: 'ew')
326
+
327
+ # Toggle button below notable dates
328
+ TkButton.new(grid_f) {
329
+ text t[:toggle_label]; font fonts[5] # ft_note_d
330
+ relief 'flat'; borderwidth 0
331
+ foreground t[:dkgray]; background t[:bg]
332
+ activebackground t[:bg]; activeforeground t[:fg]
333
+ command { $dark = !$dark; $rebuild&.call }
334
+ }.grid(row: 5, column: 0, columnspan: 3, pady: [4, 0])
335
+
336
+ 3.times { |c| grid_f.grid_columnconfigure(c, weight: 1) }
337
+
338
+ frame
339
+ end
340
+
341
+ # -- Title ---------------------------------------------------------------------
342
+
343
+ $top = TkFrame.new(root) { background theme[:bg] }
344
+ $top.pack(fill: 'x', pady: [8, 2])
345
+
346
+ $title_lbl = TkLabel.new($top) {
347
+ text $year.to_s; font ft_title; foreground theme[:title_fg]; background theme[:bg]
348
+ }
349
+ $title_lbl.pack
350
+
351
+ # -- Build & rebuild -----------------------------------------------------------
352
+
353
+ all_fonts = [ft_mheader, ft_cal, ft_cal_bold, ft_note, ft_note_b, ft_note_d]
354
+ $content = nil
355
+ $rebuild = nil
356
+
357
+ $rebuild = proc {
358
+ t = theme
359
+ $content&.destroy
360
+ root.configure(background: t[:bg])
361
+ $top.configure(background: t[:bg])
362
+ $title_lbl.configure(foreground: t[:title_fg], background: t[:bg])
363
+ root.title = "norcal - #{$year}"
364
+ $content = build_calendar(root, $year, all_fonts)
365
+ $content.pack(fill: 'both', expand: true)
366
+ }
367
+
368
+ $rebuild.call
369
+ Tk.mainloop
metadata ADDED
@@ -0,0 +1,57 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: norcal
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - baosen
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tk
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.6'
26
+ description: A local Norwegian desktop calendar with Tk GUI
27
+ email: chibaosen@gmail.com
28
+ executables:
29
+ - norcal
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - LICENSE
34
+ - README.md
35
+ - bin/norcal
36
+ homepage: https://github.com/baosen/norcal
37
+ licenses:
38
+ - ISC
39
+ metadata: {}
40
+ rdoc_options: []
41
+ require_paths:
42
+ - lib
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '3.0'
48
+ required_rubygems_version: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - ">="
51
+ - !ruby/object:Gem::Version
52
+ version: '0'
53
+ requirements: []
54
+ rubygems_version: 4.0.3
55
+ specification_version: 4
56
+ summary: Norwegian desktop calendar
57
+ test_files: []