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
|
+++
rss = "Raku's concision demonstrated in form of a tutorial"
date = Date(2021, 7, 3)
+++
@def tags = ["fun", "raku", "clipboard"]
# Writing a Clipboard Manager
!!! note "A word of protest:"
This was intended to be presented in [The Raku Conference][],
however the organizers insisted on using [Zoom][] and [Skype][],
which are privacy invasive platforms running on proprietary software
and shadily managed.
\toc
## Motivation
Clipboard management is very important to my workflow. To me,
a clipboard manager is useful in two ways:
1. It extends my (rather poor) temporary mundane memory by caching
a few dozens of most recent selections.
2. It synchronizes clipboard and primary selections.
Since some programs only support one kind of selection,
this is particularly useful.
For the first point, I have to be able to choose from the history
by pressing a few keystrokes. Having to touch the mouse during
writing sessions is unacceptable. The menu dropping down from the systray
is also undesirable because I have a multi-monitor setup. This narrows down
to only one plausible option: [Diodon][], which I having been using on Debian
for at least two years. However, as I was migrating to NixOS earlier
last month, [I was unable to package it for Nix][126190].
Naturally, I went looking for [alternatives][], most of which I had
tried before and did not satisfy my requirements. [clipmenu][] got
my attention however: it was made to work with dmenu(-compliant launchers),
which I had a rather nice experience with in [Sxmo][] on my [PinePhone][].
However, I use [awesome][] on my workstation and its widget toolkit covers
my launcher and menu need perfectly. I don't need [dmenu][] and do not
wish to spend time configuring and theming it. Plus, the architecture
of dmenu scripts and awesome widgets vastly differs: while awesome
executes the input programs, dmenu is called from the scripts.
## Inspirations and Design
As even the most plausible candidate is not a suitable replacement,
I would need to write my own clipboard manager. clipmenu is not really
a good base though because it's written in shell script, something I ain't
fluent in.[^1] Its idea is brilliant however:
> 1. `clipmenud` uses `clipnotify` to wait for new clipboard events.
> 2. If `clipmenud` detects changes to the clipboard contents,
> it writes them out to the cache directory
> and an index using a hash as the filename.
I later translated [clipnotify][] to [Zig][][^2] and called it [clipbuzz][].[^3]
From clipbuzz's usage,
```sh
while clipbuzz
do # something with xclip or xsel
done
```
and this is exactly how yet another clipboard manager was written,
but before we get there, let's talk about this article's sponsor!
I'm kidding d-; though we cannot jump into the implementation just yet:
we only resolved the first point out of two. How about the data structure?
Hashing sounds like overengineering in this case: nobody needs more than
a few dozen entries[^4] and hashes are not very memorable. Printable characters
can serve much better as indices.
What? What happens when we run out of them? We reuse/recycle them![^5]
They would also fit within one single line, heck, we just store all of them
in order inside a file and rotate each time there's a new selection.
Picking would just be moving a char to the beginning. The entire
cache directory can just look something like this:
```console
$ ls $XDG_CACHE_HOME/$project
order
R
A
K
U
```
Wait, is that a sign? We must use [Raku][] to implement `$project` then…
Speaking of `$project`, I planned to use it with awesome and [vicious][]
so let's call it something brutal, like a *cutting board*, which is *thớt*
in Vietnamese, an Internet slang for *thread*. Cool, now we have
the daemon name, and conventionally the client shall be *threac*,
or *threa client*.
## Daemon Implementation
### Reading Inputs
Raku was chosen[^6] for the ease of text manipulation and seamless interfacing
with external programs. I learned it quite a while ago and has always been
waiting for a chance to do something more practical with it, other than
competitive programming which isn't a good fit due to Rakudo's poor performance.
In Raku, the snippet from clipbuzz's README becomes:
```sh
while run 'clipbuzz' {
# do something with xclip or xsel
}
```
Out of all languages I know, this is by far the simplest way to [run][]
an external program. Most would require one to import something
or do something with the call's return value, and don't even get me start
on POSIX *fork* and *exec* model.
OK, now what are we gonna do with `xclip`? One obvious thing would be
to read the current selection. Raku got you covered, fam:
```sh
my $selection = qx/xclip -out/;
```
Remember when I said Raku can seamlessly interact with external programs?
[qx][] is how you capture their standard output, it is really that simple.
But wait, which selection is that? No worries, `xclip` supports both
primary and clipboard:
```sh
my $primary = qx/xclip -out -selection primary/;
my $clipbroad = qx/xclip -out -selection clipboard/;
```
### Cache Directory Setup
This is when we write those selection down for later use, right?
Well, we need to figure out where to save them first. According to
[XDG Base Directory Specification][XDG], `$XDG_CACHE_HOME` shall
falls back to `$HOME/.cache`:
```sh
my $XDG_CACHE_HOME = %*ENV<XDG_CACHE_HOME> // path $*HOME / '.cache':;
```
For convenience purposes, I defined the `/` operator as an alias
for path concatination:
```sh
multi sub infix:</>($parent, $child) { add $parent: $child }
```
With `$XDG_CACHE_HOME` defined, we can prepare the base directory as follows:
```sh
my $base = $XDG_CACHE_HOME.IO / 'threa';
mkdir $base: unless $base.e;
die "thread: $base: File exists" when $base.f;
```
As [a wise man once said][roles],
> As it often happens, writing an article ends up with a bug found in Rakudo.
In this case, there's a [bug in mkdir][1507] that makes it happily returns
even if the target path is a file. I'm trying to fix it at the moment
but [a test][4408] is still failing. *Update: it passed after a maintainer
bumped the dependencies to the patched version.*
Anyway, back to our clipboard manager. Here we are using
[flow controllers][control] such as `unless` and `when` in the form
of *statement modifiers*, which can sometimes be easier on eyes
keeping the code flat. Existence checks like `e` (exists) and `f` (file)
are also really handy. Next, we check on the `order`:
```sh
constant $ALNUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
sub valid($path) {
return False unless $path.f;
so /^\w\w+$/ && .chars == .comb.unique given trim slurp $path
}
my $order = $base / 'order';
spurt $order, $ALNUM unless valid $order;
```
Instead of printable, we only allow alphanumerics and fallback to
the uppercase ones (mainly because my screen can only fit as much vertically),
unless `$XDG_CACHE_HOME/threa/order` is a file, contains at least
two unique alphanumerics (exclusively). Reading and writing files in Raku
is incredibly trivial, just `slurp` and `spurt` the path. Since we are not
interested in whitespaces, they are `trim`'ed from `order`. Notice that
Raku allows subroutines to be called without any parentheses—I love Lisp,
but opening parenthesis *after* the function name always confuses me,
especially when nested.
As you might have guessed, `given` is another statement modifier
setting the [topic variable][] that is particularly useful
in [pointfree programming][], where regular expressions (e.g. `/^\w\w+$/`)
are matched against directly and methods are called without specifying
the object. Raku is also a weakly-typed language: `.comb.unique` (a list
of unique characters) is coerced into an integer when compared to one
(number of `.chars`).
### Comparing and Saving Selections
What do we do with the order then? First we can determine the latest
selection and compare it to the ones we got from `xclip` earlier
to see which one is really new. We'll also need to rotate the order,
i.e. write the new selection to the `$last` file and move it in front
of the others that we `$keep` as-is:
```sh
my ($first, $keep, $last) = do
given trim slurp $order { .comb.first, .chop, .substr: *-1 }
my ($other, $content) = do given try slurp $base / $first or '' {
when * ne $primary { 'clipboard', $primary }
when * ne $clipboard { 'primary', $clipboard }
}
```
On the first few run, probably the cache files don't exist just yet,
so we fall them back to empty ones using `try ... or ...`. We need
to know the `$other` selection (outdated one) to later synchronize them both.
In case of reselection, neither is updated and we simply skip this iteration:
```sh
next unless $other;
```
Otherwise, let's go ahead, write down the `$content`, rotate `$order`
and synchronize with the `$other` selection:
```sh
my $path = $base / $last;
spurt $path, $content;
spurt $order, $last ~ $keep;
run <xclip -in -selection>, $other, $path
```
That's it, now put the daemon in `$PATH` and run it in `~/.xinitrc`
or something IDK. If you're worried that some selection might be
too big to read that you'll the next event, asynchronize the `qx` calls
by prefixing them with `start`, and `await` the results later on.
It is *that* easy.
### Command-Line Interface
Hol up, what if I want to store the cache elsewhere or use another set
of characters? *"Then you can go right ahead and have an intercourse
with yourself, you ungrateful little piece of [redacted]."* I would have said
this were I to implement this in other languages, but luckily I got Raku,
and Raku got `sub MAIN`:
```sh
sub MAIN(
:$children where /^\w\w+$/ = $ALNUM, #= alphanumerics
:$parent = $XDG_CACHE_HOME #= cache path
) {
my $snowflakes = $children.comb.unique.join;
my $base = $parent.IO / 'threa';
my $order = $base / 'order';
while run 'clipbuzz' {
...
spurt $order, $snowflakes unless valid $order;
...
}
}
```
No matter how cool you think this is, it is cooler, I mean, look:
```console
$ thread --help
Usage:
thread [--children[=Str where { ... }]] [--parent=<Str>]
--children[=Str where { ... }] alphanumerics
--parent=<Str> cache path
```
## Client Implementation
### Back-End
Following the Unix™ philosophy, `threac` will do only one thing and do it well:
it shall take the chosen selection and *schedule* it to move to the beginning:
```sh
my $XDG_CACHE_HOME = %*ENV<XDG_CACHE_HOME> // path add $*HOME: '.cache':;
sub MAIN(
$choice where /^\w?$/, #= alphanumeric
:$parent = $XDG_CACHE_HOME #= cache path
) {
my $base = $parent.IO.add: 'threa';
my $order = add $base: 'order';
spurt $order, S/$choice(.*)/$0$choice/ with $order.slurp;
my $path = $base.add: $choice;
run 'xclip', $path
}
```
The highlight here is the non-destructive substitution `S///`,
which allow regex substitution in a pointfree and pure manner.
Though, instead of moving `$choice` to top of the deque, we place it
at the bottom and use `xclip` to trigger the daemon to do it
and synchronize between selections.
### Front-End
Note that `threac` does not give any output: selection history (by default)
are stored in a standard and convenient location to be read by any front-end
of choice. For awesome I made a menu whose each entry is wired to `threac`
and `xdotool` (to simulate primary paste with `S-Insert`) and bind
the whole thing to a keyboard shortcut.
```
local base = os.getenv("HOME") .. "/.cache/threa/"
local command = "threac %s && xdotool key shift+Insert"
local f = io.open(base .. "order")
local order = f:read("*a")
f:close()
local items = {}
for c in order:gmatch(".") do
local f = io.open(base .. c)
table.insert(items, {f:read("*a"):gsub("\n", " "), function ()
awful.spawn.with_shell(command:format(c))
end})
f:close()
end
awful.menu{items = items, theme = {width = 911}}:show()
```
## Conclusion
Through writing the clipboard manager [threa][], which is released
under [GNU GPLv3+][] on [SourceHut][], we have discovered a few features
of Raku that make it a great *scripting* language:
* Out-of-box CLI support:
- Running programs and capturing output
- Environment variables
- File system operations
- Builtin argument parser
* Concision:
- Statement modifiers
- Topic variable
- First-class regex
- Trivial asynchronization
As a generic programming language, Raku has other classes of characteristics
that makes it useful in other larger projects such as grammars (i.e. regex
on steroids) and OOP for human beings. It is a truly versatile language
and I really hope my words can convince someone new to try it out!
[^1]: I ain't proud of this, okay?
[^2]: I'm obsessed with exotic languages.
[^3]: The *z*'s are for Zig, how original, I know.
[^4]: [*citation needed*]
[^5]: Wow much environment!
[^6]: By some supernatural being of course!
[The Raku Conference]: https://conf.raku.org
[Zoom]: https://stallman.org/zoom.html
[Skype]: https://stallman.org/skype.html
[Diodon]: https://launchpad.net/diodon
[126190]: https://github.com/NixOS/nixpkgs/pull/126190
[alternatives]: https://search.nixos.org/packages?query=clip
[clipmenu]: https://github.com/cdown/clipmenu
[Sxmo]: https://sxmo.org
[PinePhone]: https://www.pine64.org/pinephone
[awesome]: https://awesomewm.org
[dmenu]: https://tools.suckless.org/dmenu
[clipnotify]: https://github.com/cdown/clipnotify
[Zig]: https://ziglang.org
[clipbuzz]: https://git.sr.ht/~cnx/clipbuzz
[vicious]: https://vicious.rtfd.io
[Raku]: https://raku.org
[run]: https://docs.raku.org/routine/run
[qx]: https://docs.raku.org/syntax/qx
[XDG]: https://specifications.freedesktop.org/basedir-spec/latest/ar01s03.html
[roles]: https://vrurg.github.io/2021/06/16/article-on-roles
[1507]: https://github.com/MoarVM/MoarVM/pull/1507
[4408]: https://github.com/rakudo/rakudo/pull/4408
[control]: https://docs.raku.org/language/control
[topic variable]: https://docs.raku.org/syntax/$_
[pointfree programming]: https://raku-advent.blog/2020/12/22/draft-whats-the-point-of-point-free-programming
[threa]: https://sr.ht/~cnx/threa
[GNU GPLv3+]: https://www.gnu.org/licenses/gpl-3.0
[SourceHut]: https://sourcehut.org
|