shortcutxl 0.2.12 → 0.2.13

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 (110) hide show
  1. package/README.md +26 -26
  2. package/agent-docs/README.md +397 -397
  3. package/agent-docs/docs/compaction.md +390 -390
  4. package/agent-docs/docs/custom-provider.md +580 -580
  5. package/agent-docs/docs/extensions.md +1971 -1971
  6. package/agent-docs/docs/packages.md +209 -209
  7. package/agent-docs/docs/rpc.md +1317 -1317
  8. package/agent-docs/docs/sdk.md +962 -962
  9. package/agent-docs/docs/session.md +412 -412
  10. package/agent-docs/docs/termux.md +127 -127
  11. package/agent-docs/docs/tui.md +887 -887
  12. package/agent-docs/examples/README.md +25 -25
  13. package/agent-docs/examples/extensions/README.md +205 -205
  14. package/agent-docs/examples/extensions/antigravity-image-gen.ts +447 -447
  15. package/agent-docs/examples/extensions/auto-commit-on-exit.ts +49 -49
  16. package/agent-docs/examples/extensions/bash-spawn-hook.ts +30 -30
  17. package/agent-docs/examples/extensions/bookmark.ts +50 -50
  18. package/agent-docs/examples/extensions/built-in-tool-renderer.ts +256 -256
  19. package/agent-docs/examples/extensions/claude-rules.ts +86 -86
  20. package/agent-docs/examples/extensions/commands.ts +75 -75
  21. package/agent-docs/examples/extensions/confirm-destructive.ts +59 -59
  22. package/agent-docs/examples/extensions/custom-compaction.ts +126 -126
  23. package/agent-docs/examples/extensions/custom-footer.ts +63 -63
  24. package/agent-docs/examples/extensions/custom-header.ts +73 -73
  25. package/agent-docs/examples/extensions/custom-provider-anthropic/index.ts +660 -660
  26. package/agent-docs/examples/extensions/custom-provider-gitlab-duo/index.ts +362 -362
  27. package/agent-docs/examples/extensions/custom-provider-gitlab-duo/test.ts +88 -88
  28. package/agent-docs/examples/extensions/custom-provider-qwen-cli/index.ts +349 -349
  29. package/agent-docs/examples/extensions/dirty-repo-guard.ts +56 -56
  30. package/agent-docs/examples/extensions/doom-overlay/doom-component.ts +133 -133
  31. package/agent-docs/examples/extensions/doom-overlay/doom-keys.ts +108 -108
  32. package/agent-docs/examples/extensions/doom-overlay/index.ts +74 -74
  33. package/agent-docs/examples/extensions/dynamic-resources/index.ts +15 -15
  34. package/agent-docs/examples/extensions/dynamic-tools.ts +77 -77
  35. package/agent-docs/examples/extensions/event-bus.ts +43 -43
  36. package/agent-docs/examples/extensions/file-trigger.ts +41 -41
  37. package/agent-docs/examples/extensions/git-checkpoint.ts +53 -53
  38. package/agent-docs/examples/extensions/handoff.ts +155 -155
  39. package/agent-docs/examples/extensions/hello.ts +25 -25
  40. package/agent-docs/examples/extensions/inline-bash.ts +94 -94
  41. package/agent-docs/examples/extensions/input-transform.ts +43 -43
  42. package/agent-docs/examples/extensions/interactive-shell.ts +209 -209
  43. package/agent-docs/examples/extensions/mac-system-theme.ts +47 -47
  44. package/agent-docs/examples/extensions/message-renderer.ts +59 -59
  45. package/agent-docs/examples/extensions/minimal-mode.ts +430 -430
  46. package/agent-docs/examples/extensions/modal-editor.ts +90 -90
  47. package/agent-docs/examples/extensions/model-status.ts +31 -31
  48. package/agent-docs/examples/extensions/notify.ts +55 -55
  49. package/agent-docs/examples/extensions/overlay-qa-tests.ts +936 -936
  50. package/agent-docs/examples/extensions/overlay-test.ts +159 -159
  51. package/agent-docs/examples/extensions/permission-gate.ts +37 -37
  52. package/agent-docs/examples/extensions/pirate.ts +47 -47
  53. package/agent-docs/examples/extensions/plan-mode/index.ts +363 -363
  54. package/agent-docs/examples/extensions/preset.ts +418 -418
  55. package/agent-docs/examples/extensions/protected-paths.ts +30 -30
  56. package/agent-docs/examples/extensions/qna.ts +122 -122
  57. package/agent-docs/examples/extensions/question.ts +278 -278
  58. package/agent-docs/examples/extensions/questionnaire.ts +440 -440
  59. package/agent-docs/examples/extensions/rainbow-editor.ts +90 -90
  60. package/agent-docs/examples/extensions/reload-runtime.ts +37 -37
  61. package/agent-docs/examples/extensions/rpc-demo.ts +124 -124
  62. package/agent-docs/examples/extensions/sandbox/index.ts +324 -324
  63. package/agent-docs/examples/extensions/send-user-message.ts +97 -97
  64. package/agent-docs/examples/extensions/session-name.ts +27 -27
  65. package/agent-docs/examples/extensions/shutdown-command.ts +69 -69
  66. package/agent-docs/examples/extensions/snake.ts +343 -343
  67. package/agent-docs/examples/extensions/space-invaders.ts +566 -566
  68. package/agent-docs/examples/extensions/ssh.ts +233 -233
  69. package/agent-docs/examples/extensions/status-line.ts +40 -40
  70. package/agent-docs/examples/extensions/subagent/agents.ts +130 -130
  71. package/agent-docs/examples/extensions/subagent/index.ts +1068 -1068
  72. package/agent-docs/examples/extensions/summarize.ts +206 -206
  73. package/agent-docs/examples/extensions/system-prompt-header.ts +17 -17
  74. package/agent-docs/examples/extensions/timed-confirm.ts +72 -72
  75. package/agent-docs/examples/extensions/titlebar-spinner.ts +58 -58
  76. package/agent-docs/examples/extensions/todo.ts +314 -314
  77. package/agent-docs/examples/extensions/tool-override.ts +146 -146
  78. package/agent-docs/examples/extensions/tools.ts +145 -145
  79. package/agent-docs/examples/extensions/trigger-compact.ts +40 -40
  80. package/agent-docs/examples/extensions/truncated-tool.ts +194 -194
  81. package/agent-docs/examples/extensions/widget-placement.ts +17 -17
  82. package/agent-docs/examples/extensions/with-deps/index.ts +37 -37
  83. package/agent-docs/examples/rpc-extension-ui.ts +654 -654
  84. package/agent-docs/examples/sdk/01-minimal.ts +22 -22
  85. package/agent-docs/examples/sdk/02-custom-model.ts +48 -48
  86. package/agent-docs/examples/sdk/03-custom-prompt.ts +55 -55
  87. package/agent-docs/examples/sdk/04-skills.ts +53 -53
  88. package/agent-docs/examples/sdk/05-tools.ts +56 -56
  89. package/agent-docs/examples/sdk/06-extensions.ts +88 -88
  90. package/agent-docs/examples/sdk/07-context-files.ts +40 -40
  91. package/agent-docs/examples/sdk/08-prompt-templates.ts +47 -47
  92. package/agent-docs/examples/sdk/09-api-keys-and-oauth.ts +48 -48
  93. package/agent-docs/examples/sdk/10-settings.ts +54 -54
  94. package/agent-docs/examples/sdk/11-sessions.ts +48 -48
  95. package/agent-docs/examples/sdk/12-full-control.ts +82 -82
  96. package/agent-docs/examples/sdk/README.md +144 -144
  97. package/agent-docs/xll-spec.md +110 -110
  98. package/dist/core/auth-storage.js +21 -2
  99. package/package.json +1 -1
  100. package/xll/ShortcutXL.xll +0 -0
  101. package/xll/modules/debug_render.py +272 -272
  102. package/xll/modules/gameboy.py +241 -241
  103. package/xll/modules/pong.py +188 -188
  104. package/xll/modules/shortcut_xl/_diff_highlight.py +176 -0
  105. package/xll/modules/shortcut_xl/_log.py +12 -12
  106. package/xll/modules/shortcut_xl/_registry.py +44 -44
  107. package/xll/modules/stocks.py +100 -100
  108. /package/skills/{com-advanced-api → COM-advanced-api}/SKILL.md +0 -0
  109. /package/skills/{com-advanced-api → COM-advanced-api}/excel-type-library.py +0 -0
  110. /package/skills/{com-advanced-api → COM-advanced-api}/office-type-library.py +0 -0
@@ -1,188 +1,188 @@
1
- """
2
- Pong in Excel — type =pong_start() to play!
3
-
4
- Two AI paddles rally a ball across a 30x20 cell grid.
5
- Type =pong_stop() to end the game.
6
- """
7
- import shortcut_xl
8
- from shortcut_xl import xl_func
9
- import random
10
-
11
- WIDTH = 30
12
- HEIGHT = 20
13
- PADDLE_H = 4
14
- FPS = 30
15
-
16
- # Grid occupies A3:AD22 (20 rows x 30 cols)
17
- _GRID_RANGE = "A3:AD22"
18
-
19
- _state = {
20
- 'ball_x': 15.0, 'ball_y': 10.0,
21
- 'ball_dx': 1.0, 'ball_dy': 0.7,
22
- 'paddle_l': 8, 'paddle_r': 8,
23
- 'score_l': 0, 'score_r': 0,
24
- 'running': False,
25
- }
26
-
27
-
28
- def _reset_ball():
29
- _state['ball_x'] = WIDTH / 2.0
30
- _state['ball_y'] = HEIGHT / 2.0
31
- _state['ball_dx'] = random.choice([-1.0, 1.0])
32
- _state['ball_dy'] = random.uniform(-1.0, 1.0)
33
-
34
-
35
- def _render():
36
- # Build the grid
37
- grid = []
38
- for r in range(HEIGHT):
39
- row = []
40
- for c in range(WIDTH):
41
- ch = ""
42
- # Left paddle
43
- if c == 0 and _state['paddle_l'] <= r < _state['paddle_l'] + PADDLE_H:
44
- ch = chr(9608) # full block
45
- # Right paddle
46
- elif c == WIDTH - 1 and _state['paddle_r'] <= r < _state['paddle_r'] + PADDLE_H:
47
- ch = chr(9608)
48
- # Ball
49
- elif int(round(_state['ball_y'])) == r and int(round(_state['ball_x'])) == c:
50
- ch = chr(9679) # black circle
51
- # Center line
52
- elif c == WIDTH // 2 and r % 2 == 0:
53
- ch = chr(9474) # light vertical
54
- row.append(ch)
55
- grid.append(row)
56
-
57
- # Write entire frame in one batch — one repaint, one retry point
58
- score = f"PONG {_state['score_l']} : {_state['score_r']}"
59
-
60
- def _paint(app):
61
- # Suppress auto-recalculation — without this every 600-cell write
62
- # triggers a full recalc cycle that fights our rendering and causes
63
- # the visible "flashing" effect.
64
- app.Calculation = -4135 # xlCalculationManual
65
-
66
- sheet = app.ActiveSheet
67
-
68
- # One-time setup: make cells square-ish and use a monospace font
69
- # so the Unicode glyphs (█ ● │) are actually visible.
70
- if not _state.get('_formatted'):
71
- _state['_formatted'] = True
72
- area = sheet.Range(_GRID_RANGE)
73
- area.ColumnWidth = 2.14
74
- area.Font.Name = "Consolas"
75
- area.Font.Size = 11
76
- area.HorizontalAlignment = -4108 # xlCenter
77
-
78
- # Grid (20x30) — use direct range address, NOT Resize()
79
- # win32com Resize() is broken: returns only the bottom-right cell
80
- grid_com = tuple(tuple(row) for row in grid)
81
- sheet.Range(_GRID_RANGE).Value = grid_com
82
- # Scoreboard + instructions
83
- sheet.Range("A1").Value = score
84
- sheet.Range("A2").Value = "pong_start() to play pong_stop() to end"
85
-
86
- shortcut_xl.xl_batch(_paint)
87
-
88
-
89
- def _tick():
90
- shortcut_xl.xl_log(f"_tick: running={_state['running']}")
91
- if not _state['running']:
92
- return
93
-
94
- try:
95
- _tick_inner()
96
- shortcut_xl.xl_log("_tick: frame OK")
97
- except Exception as e:
98
- shortcut_xl.xl_log(f"pong tick error: {e}")
99
- import traceback
100
- shortcut_xl.xl_log(traceback.format_exc())
101
- _state['running'] = False
102
- shortcut_xl.schedule_call(_restore_calc, 0.3)
103
- return
104
-
105
-
106
- def _tick_inner():
107
- s = _state
108
-
109
- # Move ball
110
- s['ball_x'] += s['ball_dx']
111
- s['ball_y'] += s['ball_dy']
112
-
113
- # Bounce top/bottom
114
- if s['ball_y'] <= 0:
115
- s['ball_y'] = 0
116
- s['ball_dy'] = abs(s['ball_dy'])
117
- elif s['ball_y'] >= HEIGHT - 1:
118
- s['ball_y'] = HEIGHT - 1
119
- s['ball_dy'] = -abs(s['ball_dy'])
120
-
121
- # Left paddle check
122
- if s['ball_x'] <= 1:
123
- if s['paddle_l'] - 0.5 <= s['ball_y'] < s['paddle_l'] + PADDLE_H + 0.5:
124
- s['ball_dx'] = abs(s['ball_dx'])
125
- s['ball_x'] = 1
126
- # Add spin based on where ball hit paddle
127
- hit_pos = (s['ball_y'] - s['paddle_l']) / PADDLE_H
128
- s['ball_dy'] = (hit_pos - 0.5) * 2.0
129
- else:
130
- s['score_r'] += 1
131
- _reset_ball()
132
-
133
- # Right paddle check
134
- if s['ball_x'] >= WIDTH - 2:
135
- if s['paddle_r'] - 0.5 <= s['ball_y'] < s['paddle_r'] + PADDLE_H + 0.5:
136
- s['ball_dx'] = -abs(s['ball_dx'])
137
- s['ball_x'] = WIDTH - 2
138
- hit_pos = (s['ball_y'] - s['paddle_r']) / PADDLE_H
139
- s['ball_dy'] = (hit_pos - 0.5) * 2.0
140
- else:
141
- s['score_l'] += 1
142
- _reset_ball()
143
-
144
- # AI: move paddles toward ball with slight delay
145
- ball_y = s['ball_y']
146
- for paddle in ('paddle_l', 'paddle_r'):
147
- target = int(ball_y) - PADDLE_H // 2
148
- diff = target - s[paddle]
149
- # Add some imperfection
150
- speed = 1 if abs(diff) < 3 else 2
151
- if diff > 0:
152
- s[paddle] = min(s[paddle] + speed, HEIGHT - PADDLE_H)
153
- elif diff < 0:
154
- s[paddle] = max(s[paddle] - speed, 0)
155
-
156
- # Speed up slightly over time
157
- if abs(s['ball_dx']) < 2.5:
158
- s['ball_dx'] *= 1.005
159
-
160
- _render()
161
- shortcut_xl.schedule_call(_tick, 1.0 / FPS)
162
-
163
-
164
- def _restore_calc():
165
- """Restore automatic calculation after the game ends."""
166
- def _do(app):
167
- app.Calculation = -4105 # xlCalculationAutomatic
168
- shortcut_xl.xl_batch(_do)
169
-
170
-
171
- @xl_func
172
- def pong_start():
173
- if _state['running']:
174
- return "Already running!"
175
- _state['running'] = True
176
- _state['score_l'] = 0
177
- _state['score_r'] = 0
178
- _state['_formatted'] = False
179
- _reset_ball()
180
- shortcut_xl.schedule_call(_tick, 0.1)
181
- return "PONG!"
182
-
183
-
184
- @xl_func
185
- def pong_stop():
186
- _state['running'] = False
187
- shortcut_xl.schedule_call(_restore_calc, 0.3)
188
- return "Stopped"
1
+ """
2
+ Pong in Excel — type =pong_start() to play!
3
+
4
+ Two AI paddles rally a ball across a 30x20 cell grid.
5
+ Type =pong_stop() to end the game.
6
+ """
7
+ import shortcut_xl
8
+ from shortcut_xl import xl_func
9
+ import random
10
+
11
+ WIDTH = 30
12
+ HEIGHT = 20
13
+ PADDLE_H = 4
14
+ FPS = 30
15
+
16
+ # Grid occupies A3:AD22 (20 rows x 30 cols)
17
+ _GRID_RANGE = "A3:AD22"
18
+
19
+ _state = {
20
+ 'ball_x': 15.0, 'ball_y': 10.0,
21
+ 'ball_dx': 1.0, 'ball_dy': 0.7,
22
+ 'paddle_l': 8, 'paddle_r': 8,
23
+ 'score_l': 0, 'score_r': 0,
24
+ 'running': False,
25
+ }
26
+
27
+
28
+ def _reset_ball():
29
+ _state['ball_x'] = WIDTH / 2.0
30
+ _state['ball_y'] = HEIGHT / 2.0
31
+ _state['ball_dx'] = random.choice([-1.0, 1.0])
32
+ _state['ball_dy'] = random.uniform(-1.0, 1.0)
33
+
34
+
35
+ def _render():
36
+ # Build the grid
37
+ grid = []
38
+ for r in range(HEIGHT):
39
+ row = []
40
+ for c in range(WIDTH):
41
+ ch = ""
42
+ # Left paddle
43
+ if c == 0 and _state['paddle_l'] <= r < _state['paddle_l'] + PADDLE_H:
44
+ ch = chr(9608) # full block
45
+ # Right paddle
46
+ elif c == WIDTH - 1 and _state['paddle_r'] <= r < _state['paddle_r'] + PADDLE_H:
47
+ ch = chr(9608)
48
+ # Ball
49
+ elif int(round(_state['ball_y'])) == r and int(round(_state['ball_x'])) == c:
50
+ ch = chr(9679) # black circle
51
+ # Center line
52
+ elif c == WIDTH // 2 and r % 2 == 0:
53
+ ch = chr(9474) # light vertical
54
+ row.append(ch)
55
+ grid.append(row)
56
+
57
+ # Write entire frame in one batch — one repaint, one retry point
58
+ score = f"PONG {_state['score_l']} : {_state['score_r']}"
59
+
60
+ def _paint(app):
61
+ # Suppress auto-recalculation — without this every 600-cell write
62
+ # triggers a full recalc cycle that fights our rendering and causes
63
+ # the visible "flashing" effect.
64
+ app.Calculation = -4135 # xlCalculationManual
65
+
66
+ sheet = app.ActiveSheet
67
+
68
+ # One-time setup: make cells square-ish and use a monospace font
69
+ # so the Unicode glyphs (█ ● │) are actually visible.
70
+ if not _state.get('_formatted'):
71
+ _state['_formatted'] = True
72
+ area = sheet.Range(_GRID_RANGE)
73
+ area.ColumnWidth = 2.14
74
+ area.Font.Name = "Consolas"
75
+ area.Font.Size = 11
76
+ area.HorizontalAlignment = -4108 # xlCenter
77
+
78
+ # Grid (20x30) — use direct range address, NOT Resize()
79
+ # win32com Resize() is broken: returns only the bottom-right cell
80
+ grid_com = tuple(tuple(row) for row in grid)
81
+ sheet.Range(_GRID_RANGE).Value = grid_com
82
+ # Scoreboard + instructions
83
+ sheet.Range("A1").Value = score
84
+ sheet.Range("A2").Value = "pong_start() to play pong_stop() to end"
85
+
86
+ shortcut_xl.xl_batch(_paint)
87
+
88
+
89
+ def _tick():
90
+ shortcut_xl.xl_log(f"_tick: running={_state['running']}")
91
+ if not _state['running']:
92
+ return
93
+
94
+ try:
95
+ _tick_inner()
96
+ shortcut_xl.xl_log("_tick: frame OK")
97
+ except Exception as e:
98
+ shortcut_xl.xl_log(f"pong tick error: {e}")
99
+ import traceback
100
+ shortcut_xl.xl_log(traceback.format_exc())
101
+ _state['running'] = False
102
+ shortcut_xl.schedule_call(_restore_calc, 0.3)
103
+ return
104
+
105
+
106
+ def _tick_inner():
107
+ s = _state
108
+
109
+ # Move ball
110
+ s['ball_x'] += s['ball_dx']
111
+ s['ball_y'] += s['ball_dy']
112
+
113
+ # Bounce top/bottom
114
+ if s['ball_y'] <= 0:
115
+ s['ball_y'] = 0
116
+ s['ball_dy'] = abs(s['ball_dy'])
117
+ elif s['ball_y'] >= HEIGHT - 1:
118
+ s['ball_y'] = HEIGHT - 1
119
+ s['ball_dy'] = -abs(s['ball_dy'])
120
+
121
+ # Left paddle check
122
+ if s['ball_x'] <= 1:
123
+ if s['paddle_l'] - 0.5 <= s['ball_y'] < s['paddle_l'] + PADDLE_H + 0.5:
124
+ s['ball_dx'] = abs(s['ball_dx'])
125
+ s['ball_x'] = 1
126
+ # Add spin based on where ball hit paddle
127
+ hit_pos = (s['ball_y'] - s['paddle_l']) / PADDLE_H
128
+ s['ball_dy'] = (hit_pos - 0.5) * 2.0
129
+ else:
130
+ s['score_r'] += 1
131
+ _reset_ball()
132
+
133
+ # Right paddle check
134
+ if s['ball_x'] >= WIDTH - 2:
135
+ if s['paddle_r'] - 0.5 <= s['ball_y'] < s['paddle_r'] + PADDLE_H + 0.5:
136
+ s['ball_dx'] = -abs(s['ball_dx'])
137
+ s['ball_x'] = WIDTH - 2
138
+ hit_pos = (s['ball_y'] - s['paddle_r']) / PADDLE_H
139
+ s['ball_dy'] = (hit_pos - 0.5) * 2.0
140
+ else:
141
+ s['score_l'] += 1
142
+ _reset_ball()
143
+
144
+ # AI: move paddles toward ball with slight delay
145
+ ball_y = s['ball_y']
146
+ for paddle in ('paddle_l', 'paddle_r'):
147
+ target = int(ball_y) - PADDLE_H // 2
148
+ diff = target - s[paddle]
149
+ # Add some imperfection
150
+ speed = 1 if abs(diff) < 3 else 2
151
+ if diff > 0:
152
+ s[paddle] = min(s[paddle] + speed, HEIGHT - PADDLE_H)
153
+ elif diff < 0:
154
+ s[paddle] = max(s[paddle] - speed, 0)
155
+
156
+ # Speed up slightly over time
157
+ if abs(s['ball_dx']) < 2.5:
158
+ s['ball_dx'] *= 1.005
159
+
160
+ _render()
161
+ shortcut_xl.schedule_call(_tick, 1.0 / FPS)
162
+
163
+
164
+ def _restore_calc():
165
+ """Restore automatic calculation after the game ends."""
166
+ def _do(app):
167
+ app.Calculation = -4105 # xlCalculationAutomatic
168
+ shortcut_xl.xl_batch(_do)
169
+
170
+
171
+ @xl_func
172
+ def pong_start():
173
+ if _state['running']:
174
+ return "Already running!"
175
+ _state['running'] = True
176
+ _state['score_l'] = 0
177
+ _state['score_r'] = 0
178
+ _state['_formatted'] = False
179
+ _reset_ball()
180
+ shortcut_xl.schedule_call(_tick, 0.1)
181
+ return "PONG!"
182
+
183
+
184
+ @xl_func
185
+ def pong_stop():
186
+ _state['running'] = False
187
+ shortcut_xl.schedule_call(_restore_calc, 0.3)
188
+ return "Stopped"
@@ -0,0 +1,176 @@
1
+ """Diff highlighting — CF overlay + MessageBox approval for cell changes.
2
+
3
+ After user code executes and dirty cells are collected, this module:
4
+ 1. Applies Conditional Formatting to highlight changed cells (yellow)
5
+ 2. Shows a Win32 MessageBox asking the user to keep or reject changes
6
+ 3. If rejected, reverts cells using oldValue/oldFormula from the diff
7
+ 4. Always cleans up CF rules before returning
8
+
9
+ Performance: CF rules are applied via batched Union ranges (not per-cell),
10
+ so highlighting cost is O(n / batch_size) COM calls, not O(n).
11
+ Revert writes are per-cell but in-process COM (~microseconds each).
12
+ """
13
+
14
+ import ctypes
15
+ from shortcut_xl._log import xl_log
16
+
17
+ # Win32 MessageBox constants
18
+ _MB_YESNO = 0x04
19
+ _MB_ICONQUESTION = 0x20
20
+ _IDYES = 6
21
+
22
+ # Excel constants
23
+ _XL_EXPRESSION = 2 # xlExpression CF type
24
+ _XL_CALC_MANUAL = -4135 # xlCalculationManual
25
+ _HIGHLIGHT_COLOR = 0x00FFFF # yellow (BGR for #FFFF00) — Track Changes color
26
+
27
+ # Max length for comma-separated address string (Excel limit)
28
+ _ADDR_BATCH_LIMIT = 255
29
+
30
+
31
+ def _group_by_sheet(dirty_cells):
32
+ by_sheet = {}
33
+ for cell in dirty_cells:
34
+ by_sheet.setdefault(cell['sheet'], []).append(cell)
35
+ return by_sheet
36
+
37
+
38
+ def _build_union(app, ws, addresses):
39
+ """Build a multi-area Range from address list, batching to stay under limits."""
40
+ if not addresses:
41
+ return None
42
+ batches = []
43
+ current = ""
44
+ for addr in addresses:
45
+ candidate = f"{current},{addr}" if current else addr
46
+ if len(candidate) > _ADDR_BATCH_LIMIT and current:
47
+ batches.append(current)
48
+ current = addr
49
+ else:
50
+ current = candidate
51
+ if current:
52
+ batches.append(current)
53
+
54
+ rng = ws.Range(batches[0])
55
+ for b in batches[1:]:
56
+ rng = app.Union(rng, ws.Range(b))
57
+ return rng
58
+
59
+
60
+ def _apply_cf_highlights(app, by_sheet):
61
+ """Apply CF rules to highlight changed cells. Returns cleanup state."""
62
+ cf_state = [] # (ws, count_before) for cleanup
63
+ for sheet_name, cells in by_sheet.items():
64
+ try:
65
+ ws = app.Worksheets(sheet_name)
66
+ count_before = ws.Cells.FormatConditions.Count
67
+ rng = _build_union(app, ws, [c['address'] for c in cells])
68
+ if rng is None:
69
+ continue
70
+ rng.FormatConditions.Add(Type=_XL_EXPRESSION, Formula1="=TRUE")
71
+ rule = rng.FormatConditions(rng.FormatConditions.Count)
72
+ rule.Interior.Color = _HIGHLIGHT_COLOR
73
+ cf_state.append((ws, count_before))
74
+ except Exception as e:
75
+ xl_log(f"_apply_cf_highlights({sheet_name}): {e}")
76
+ return cf_state
77
+
78
+
79
+ def _cleanup_cf(cf_state):
80
+ """Remove CF rules added during highlighting."""
81
+ for ws, count_before in cf_state:
82
+ try:
83
+ while ws.Cells.FormatConditions.Count > count_before:
84
+ ws.Cells.FormatConditions(
85
+ ws.Cells.FormatConditions.Count).Delete()
86
+ except Exception as e:
87
+ xl_log(f"_cleanup_cf: {e}")
88
+
89
+
90
+ def _revert_cells(app, dirty_cells):
91
+ """Restore old values/formulas for rejected changes.
92
+
93
+ Sets calc to manual during revert to avoid expensive recalcs
94
+ on each write, then restores original calc mode.
95
+ """
96
+ orig_calc = None
97
+ try:
98
+ orig_calc = app.Calculation
99
+ app.Calculation = _XL_CALC_MANUAL
100
+ except Exception:
101
+ pass
102
+
103
+ for sheet_name, cells in _group_by_sheet(dirty_cells).items():
104
+ try:
105
+ ws = app.Worksheets(sheet_name)
106
+ for cell in cells:
107
+ addr = cell['address']
108
+ try:
109
+ if cell.get('oldFormula'):
110
+ ws.Range(addr).Formula = cell['oldFormula']
111
+ else:
112
+ ws.Range(addr).Value = cell['oldValue']
113
+ except Exception as e:
114
+ xl_log(f"_revert_cells({sheet_name}!{addr}): {e}")
115
+ except Exception as e:
116
+ xl_log(f"_revert_cells({sheet_name}): {e}")
117
+
118
+ if orig_calc is not None:
119
+ try:
120
+ app.Calculation = orig_calc
121
+ except Exception:
122
+ pass
123
+
124
+
125
+ def highlight_and_approve(app, dirty_cells):
126
+ """Highlight changed cells with CF, show MessageBox for user approval.
127
+
128
+ Returns True if approved, False if rejected.
129
+ On rejection, all changes are reverted from dirty_cells' oldValue/oldFormula.
130
+ CF highlighting is always cleaned up before returning.
131
+ """
132
+ if not dirty_cells:
133
+ return True
134
+
135
+ by_sheet = _group_by_sheet(dirty_cells)
136
+ cf_state = []
137
+
138
+ try:
139
+ # Phase 1: Apply CF highlights
140
+ try:
141
+ app.ScreenUpdating = False
142
+ except Exception:
143
+ pass
144
+ cf_state = _apply_cf_highlights(app, by_sheet)
145
+ try:
146
+ app.ScreenUpdating = True
147
+ except Exception:
148
+ pass
149
+
150
+ # Phase 2: MessageBox — modal to Excel window
151
+ n = len(dirty_cells)
152
+ sheet_summary = ", ".join(
153
+ f"{name} ({len(cells)})" for name, cells in by_sheet.items()
154
+ )
155
+ msg = (
156
+ f"ShortcutXL modified {n} cell{'s' if n != 1 else ''} "
157
+ f"in: {sheet_summary}\n\n"
158
+ "Keep these changes?"
159
+ )
160
+ try:
161
+ hwnd = app.Hwnd
162
+ except Exception:
163
+ hwnd = 0
164
+ result = ctypes.windll.user32.MessageBoxW(
165
+ hwnd, msg, "ShortcutXL", _MB_YESNO | _MB_ICONQUESTION
166
+ )
167
+ approved = (result == _IDYES)
168
+
169
+ # Phase 3: Revert if rejected
170
+ if not approved:
171
+ _revert_cells(app, dirty_cells)
172
+
173
+ return approved
174
+
175
+ finally:
176
+ _cleanup_cf(cf_state)
@@ -1,12 +1,12 @@
1
- """Logging utility for ShortcutXL."""
2
-
3
- import os
4
- import datetime
5
-
6
-
7
- def xl_log(msg):
8
- """Append a timestamped message to %TEMP%\\shortcutxl.log."""
9
- log_path = os.path.join(os.environ.get("TEMP", "."), "shortcutxl.log")
10
- timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
11
- with open(log_path, "a", encoding="utf-8") as f:
12
- f.write(f"[{timestamp}] {msg}\n")
1
+ """Logging utility for ShortcutXL."""
2
+
3
+ import os
4
+ import datetime
5
+
6
+
7
+ def xl_log(msg):
8
+ """Append a timestamped message to %TEMP%\\shortcutxl.log."""
9
+ log_path = os.path.join(os.environ.get("TEMP", "."), "shortcutxl.log")
10
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
11
+ with open(log_path, "a", encoding="utf-8") as f:
12
+ f.write(f"[{timestamp}] {msg}\n")
@@ -1,44 +1,44 @@
1
- """@xl_func decorator — marks functions for registration as Excel UDFs.
2
-
3
- The C runtime scans _registry at xlAutoOpen time to register functions with Excel.
4
- """
5
-
6
- import inspect
7
-
8
- # Internal registry: list of (name, callable, n_args)
9
- _registry = []
10
-
11
-
12
- def xl_func(fn=None, *, name=None):
13
- """Decorator — mark a function as an Excel UDF.
14
-
15
- Usage:
16
- @xl_func
17
- def my_func(a, b):
18
- return a + b
19
-
20
- @xl_func(name="myCustomName")
21
- def my_func(a, b):
22
- return a + b
23
- """
24
- def _register(f):
25
- excel_name = name if name else f.__name__
26
- sig = inspect.signature(f)
27
- n_args = len([
28
- p for p in sig.parameters.values()
29
- if p.kind in (
30
- inspect.Parameter.POSITIONAL_OR_KEYWORD,
31
- inspect.Parameter.POSITIONAL_ONLY,
32
- )
33
- ])
34
- # Check if already registered (e.g. after a hot-reload)
35
- for i, (n, _, _) in enumerate(_registry):
36
- if n == excel_name:
37
- _registry[i] = (excel_name, f, n_args)
38
- return f
39
- _registry.append((excel_name, f, n_args))
40
- return f
41
-
42
- if fn is not None:
43
- return _register(fn)
44
- return _register
1
+ """@xl_func decorator — marks functions for registration as Excel UDFs.
2
+
3
+ The C runtime scans _registry at xlAutoOpen time to register functions with Excel.
4
+ """
5
+
6
+ import inspect
7
+
8
+ # Internal registry: list of (name, callable, n_args)
9
+ _registry = []
10
+
11
+
12
+ def xl_func(fn=None, *, name=None):
13
+ """Decorator — mark a function as an Excel UDF.
14
+
15
+ Usage:
16
+ @xl_func
17
+ def my_func(a, b):
18
+ return a + b
19
+
20
+ @xl_func(name="myCustomName")
21
+ def my_func(a, b):
22
+ return a + b
23
+ """
24
+ def _register(f):
25
+ excel_name = name if name else f.__name__
26
+ sig = inspect.signature(f)
27
+ n_args = len([
28
+ p for p in sig.parameters.values()
29
+ if p.kind in (
30
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
31
+ inspect.Parameter.POSITIONAL_ONLY,
32
+ )
33
+ ])
34
+ # Check if already registered (e.g. after a hot-reload)
35
+ for i, (n, _, _) in enumerate(_registry):
36
+ if n == excel_name:
37
+ _registry[i] = (excel_name, f, n_args)
38
+ return f
39
+ _registry.append((excel_name, f, n_args))
40
+ return f
41
+
42
+ if fn is not None:
43
+ return _register(fn)
44
+ return _register