Tags: programming, workflow, crystal
This is a writeup for my second Crystal library: i3.cr.
i3.cr is a Crystal interface for i3, a popular tiling window manager. i3 provides an IPC server for interaction, allowing clients to read window manager’s container tree and workspaces, as well as issue commands for managing i3 itself.
The protocol used by i3’s IPC server is simple: it’s a magic field (currently “
by a standard type-length-value protocol, with
the value being a fixed-structure JSON object corresponding to the type.
Messages are at the core of i3.cr. They’re not exposed directly to users, but are exchanged with
the IPC server via an event loop and marshaled into custom Crystal classes
I3::Message::Workspace) for the user to interact with.
The simplicity of i3’s IPC protocol makes it easy to manage in Crystal via a custom
class by implementing
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 class Message def self.from_io(io, format = Fmt) magic = io.gets(MAGIC.size) raise Error.new("missing or malformed message magic") unless magic && magic == MAGIC length = io.read_bytes Int32, format typeno = io.read_bytes Int32, format payload = io.gets(length) type = if (typeno >> 31).zero? MessageType.new(typeno) else EventType.new(typeno & 0x7F) end raise Error.new("missing or malformed payload") unless payload && payload.bytesize == length new(type, payload) end def to_io(io, format = Fmt) io << MAGIC io.write_bytes length, format io.write_bytes type.to_i, format io << payload end end
These two methods allow the
IO class to serialize and deserialize
Message instances via
1 2 3 4 5 # send a message over the wire socket.write_bytes message # ...and read a response response = socket.read_bytes Message
The easiest way to install i3.cr is via Crystal’s
shards dependency manager.
You can find the relevant steps in the repository
Like x_do.cr, i3.cr provides a convenient
which yields an
1 2 3 I3.act do |con| # operate on the i3 connection end
Alternatively, users can create and manage a connection explicitly:
1 2 3 con = I3::Connection.new # operate on the i3 connection con.close
Note that explicitly creating a connection requires you to close it when finished, while the block form handles resource cleanup for you.
In terms of actual operations, the entire i3 IPC protocol is supported. You can issue commands:
1 2 3 4 5 # move to workspace #2 con.command "workspace 2" # move to workspace #1, and reload the config con.command "workspace 1; reload"
As well as retrieve the current state of all workspaces, outputs, the container tree, marks, status bars, and so on.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # get all workspaces con.workspaces # get all outputs con.outputs # get the container tree con.tree # get the names of all set marks con.marks # get the first status bar's config con.bar_config(con.bar_ids.first) # get i3's runtime version con.version # get the names of all binding modes con.binding_modes # get i3's runtime configuration con.config
Each of the above returns an appropriate Crystal object:
bar_ids, and so forth.
Event subscription also works, with all i3 events currently supported:
1 2 3 4 5 6 7 8 # subscribe to all workspace events con.subscribe "workspace" con.on_event do |event| # this is safe, since we've only subscribed to one event type workspace = event.as(I3::Message::Event::Workspace) puts workspace.change end
on_event blocks the main thread, as it reads from a
with events1. This means that it can be used to halt until an appropriate event occurs, and
should be put inside of a fiber if it needs to be run asynchronously. Also note that an event
backlog can occur: events are handled FIFO.
All event response objects can be found under the
Full documentation is available here. The
I3::Message::Event namespaces contain objects that map directly to the JSON objects
sent by i3, so
the i3 reply documentation is also
This was my second Crystal library in a week, which is a pretty fast pace (one that I’m unlikely to keep up).
Where writing x_do.cr taught me about C bindings, this taught me about Crystal’s
concurrency, networking, and serialization/deserialization features. In particular, I got
my hands dirty with
JSON.mapping and IO serialization, which were a real pleasure to use compared
unpack, respectively. Fibers and channels were also interesting
(and a breeze to communicate over), but I think I prefer a traditional thread-and-queue approach
for a protocol like this.
Crystal doesn’t have mature threading support yet, which makes it difficult to create a non-blocking/callback-based event handler. When threading becomes stable, I’ll probably add support for it. ↩