A short side quests with Halloy

Estimated reading time 10 minutes

I've been using halloy as my IRC client for a few months now, and one thing that occasionally bothers me when comparing it to something like discord is the tight text. Given that Halloy is open source, I can, in fact, do something about that.

The "problem"

I'm putting air qoutes around problem here because I don't think it bothers most people. In fact, if I didn't have one half of a monitor with Halloy open, and the other half open with discord in a brower, I probably wouldn't really notice it. But once you do, it is pretty easy to tell that as far as allowing text to breathe, discord has more space:

As you can see, the text in halloy is pretty tight. It allows for more text to fit on the screen, and so you can see more of the discussion pretty easily. Which is great! But sometimes, when I'm looking between the two, my preference starts to swing towards wanting just a bit of margin, padding, spacing, whatever you'd like to call it. I just want my eyes to be able to breathe a little bit.

So, the first thing I did was take a quick peak at the documentation for halloy. It allows for custom themes and so I figured that perhaps this would include some way of controlling the spacing. As you can tell from the table of contents on this post, it did not.

Luckily for me, I built halloy from source for my install. Which means that theoritically, with a bit of elbow grease, and a lot of grepping, and hopefully less crying, I can just tweak this myself!

Spelunking in source

So, the first thing I did was the dumb approach. Because I figured it might work.

Unfortunately for me, this didn't really help me much. There was code using the word I expected to control something, but nothing really seemed familiar. And I realized that even if I found what I was looking for, it was likely I wasn't going to be able to easily tell how to change it. Or at least, that's how it felt while looking at rust code that meant very little to me and wondering if I was even in the right place.

I see some kind of builder-ish pattern going on, but what units are these? Where's the right place to chain? Does the thing that controls the height of the text messages in the IRC channel even have padding set to tweak?

So, I took a quick step backwards and asked the important question: does Halloy use some kind of framework for its UI?

Looking through the toml file I found a reference it iced

iced = { version = "0.14.0-dev", default-features = false, features = [
	"wgpu",
	"tiny-skia",
	"fira-sans",
	"tokio",
	"lazy",
	"advanced",
	"image",
] }

And reading through the first steps page I found that it defined things via widgets. Which certainly made the folder structure in the halloy project crystalize a bit:

Continuing to skip and jump around on the website eventually led me to the official API docs which talked about the layout and hey look at that example of theirs!

use iced::widget::{column, container, row};
use iced::{Fill, Element};

fn view(state: &State) -> Element<'_, Message> {
    container(
        column![
            "Top",
            row!["Left", "Right"].spacing(10),
            "Bottom"
        ]
        .spacing(10)
    )
    .padding(10)
    .center_x(Fill)
    .center_y(Fill)
    .into()
}

so now I know that I need to find the widget which handles the scrolling text area of the IRC channel, and that if I call either spacing or padding it will give me what I want. So. Where is that exactly?

Ah. It's here, or at least, I think so. This certainly seems like what I want given that the topic, input bar, users, and everything else are all the pieces I can see around the content pane, and the variables messages seems quite obvious! So! Let's give it a shot, shall we?

Changing the padding

Looking at the code in this region, I can see that the messages container is... kind of huge. If I knock out the closure that seems to be handling formatting the text itself, there's a few things of note:

let messages = container(
    scroll_view::view(
        &state.scroll_view,
        scroll_view::Kind::Channel(&state.server, channel),
        history,
        Some(previews),
        chathistory_state,
        config,
        move |message, max_nick_width, max_prefix_width| {
            ...

                    let message_content = message_content::with_context(
                        &message.content,
                        casemapping,
                        theme,
                        scroll_view::Message::Link,
                        theme::selectable_text::default,
                        ...
                    );
                    ...

                    let text_container = container(message_content);
            ...
        },
    )
    .map(Message::ScrollView),
)
.width(Length::FillPortion(2))
.height(Length::Fill);

The .width and the .height is for the container that holds all the text. Which isn't quite what I want. I want to adjust the padding between each message itself. So, for that we've got to dig deeper. Unfortunately for us, this means parsing the 258 lines of code that comes after the theme::selectable_text::default in the above snippet.

This block of code is one gigantic match statement with some fairly muscled arms that are doing a bunch of stuff. Taking each switched part, we can see the enum values that tell us about each:

match message.target.source() {
    message::Source::User(user) => {
    message::Source::Server(server_message) => {
    message::Source::Action(_) => {
    message::Source::Internal(
    message::source::Internal::Status(status),
    message::Source::Internal(
    message::source::Internal::Logs,
}

It was this point that I returned back to the source I had linked on github a moment ago in this blog post and realized that my version of Halloy is out of date. the code I just mentioned has been moved to message_view.rs and so I took a moment to update my client to the latest and greatest.

$ git pull origin main
remote: Enumerating objects: 917, done.
remote: Counting objects: 100% (424/424), done.
remote: Compressing objects: 100% (114/114), done.
remote: Total 917 (delta 369), reused 321 (delta 310), pack-reused 493 (from 2)
Receiving objects: 100% (917/917), 9.54 MiB | 43.23 MiB/s, done.
Resolving deltas: 100% (526/526), completed with 53 local objects.
From https://github.com/squidowl/halloy
 * branch              main       -> FETCH_HEAD
   64360996..205699a7  main       -> origin/main
Updating 64360996..205699a7
Fast-forward

Man, quite a few changes. Time to make my fans whine as I run cargo build --release real quick. Also, it's important to remember to not be running the binary or else halloy's build fails.

Anyway. Now that everythings moved around, I spent a bit of time finding that match statement again, and to my pleasant surprise it was no longer hundreds of lines long! There's now a message_contents.rs that seems to clean things up a bit, it handles conversion of fragements of text (plain or formatted) into spans and runs of text, a small excerpt:

data::message::Content::Plain(text) => {
    selectable_text(text).style(style).into()
}
data::message::Content::Fragments(fragments) => {
    let mut text = selectable_rich_text::<
        M,
        message::Link,
        T,
        Theme,
        Renderer,
    >(
        fragments
            .iter()
            .map(|fragment| match fragment {
                data::message::Fragment::Text(s) => span(s),
                data::message::Fragment::Channel(s) => span(s.as_str())
                    .color(theme.colors().buffer.url)
                    .link(message::Link::Channel(
                        target::Channel::from_str(
                            s.as_str(),
                            casemapping,
                        ),
                    )),

I don't think we care about the spans, but rather, we care about where this message_content method that we're actually inside of gets called. There's quite a few, but one stood out to me when I was searching:

I'm assuming that server.rs is, well, the widget controlling what the content from the IRC server is. And so this seems as good a place as any to a dive into. It had the same match statement against the messages target source as before, but with a much smaller footprint.

match message.target.source() {
    message::Source::Server(server) => {
        let message = message_content(
            ...
        );

        Some(
            container(
                row![].push_maybe(timestamp).push(message),
            )
            .into(),
        )
    }
    message::Source::Internal(
        message::source::Internal::Status(status),
    ) => {
        let message = message_content(
            ...
        );

        Some(
            container(
                row![].push_maybe(timestamp).push(message),
            )
            .into(),
        )
    }
    _ => None,
}

So, if I understood the iced documentation, and if I'm in the right place. Then this is all we should have to do in both spots:

Some(
    container(
        row![].push_maybe(timestamp).push(message),
    )
    .padding(4)
    .into(),
)

Well, that and go eat a snack since

Finished `dev` profile [optimized + debuginfo] target(s) in 8m 54s

Eight minutes compiling? Jeez. 1 I had just ran cargo build --release but I guess running it without the flag meant that more things had to be built for the first time? Ugh. Anyway. I ran up the tweaked version of halloy annnnnd

Hm. Nothing's different. Ok hold on...

Okay so padding isn't what I want. I was in HTML brain mode. Rather, it looks like spacing is what I want given:

Sets the vertical spacing between elements.
Custom margins per element do not exist in iced. You should use this method instead! While less flexible, it helps you keep spacing between elements consistent.

Okay then. Then let us now try

Some(
    container(
        row![].push_maybe(timestamp).push(message),
    )
    .spacing(10)
    .into(),
)

Alrighty, so let's just compile this again and

Ah. Dang it. Well, that's easily understood. So a container can't have padding. And I probably should have realized this when I found the spacing method. Considering that we're on the Column documentation right now! The documentation snippet at the top has

fn view(state: &State) -> Element<'_, Message> {
    column![
        "I am on top!",
        button("I am in the center!"),
        "I am below.",
    ].into()
}

in it, and so looking at the code we were just tweaking, I did the next logical thing. Move the spacing into the thing that looked sort of similar! row! looks kind of like column! to me. And so I feel it's safe to assume that perhaps they'd have a spacing method as well?

Some(
    container(
        row![].push_maybe(timestamp).push(message).spacing(10),
    )
    .into(),
)

And this compiles! However... still no extra space for my eyes to rest inbetween. Dang. So, there's something I'm not understanding, or whatever layout is going on here doesn't take space on a single row into consideration maybe? I was really hoping that this would be a simple drop in and pretend to be Emeril moment. Just, pop in, drop in a function call and bam, bob's your uncle.

Sadly. Not the case. So instead... let's take a different approach. Staring at the upper code again:

let messages = container(
    scroll_view::view(
        &state.scroll_view,
        scroll_view::Kind::Channel(&state.server, channel),
        history,
        previews,
        chathistory_state,
        config,
        message_formatter,
    )
    .map(Message::ScrollView),
)
.width(Length::FillPortion(2))
.height(Length::Fill);

I found myself asking "well, if it's not the message_formatter, then is it something in the scroll view?"

So I opened up the iced documentation and looked for the docs on that. But, I didn't see anything. Well, okay, fine, I saw some stuff about Scrollables but that's not the same thing. So, wait a minute... where's scroll_view coming from?

Ah, it's a halloy widget! And inside of that file, if I scroll past some structs and things I spot

pub fn view<'a>(
    state: &State,
    kind: Kind,
    history: &'a history::Manager,
    previews: Option<Previews<'a>>,
    chathistory_state: Option<ChatHistoryState>,
    config: &'a Config,
    formatter: impl LayoutMessage<'a> + 'a,
) -> Element<'a, Message> {
    let divider_font_size =
        config.font.size.map_or(theme::TEXT_SIZE, f32::from) - 1.0;

    let Some(history::View {
        has_more_older_messages,
        has_more_newer_messages,
        old_messages,
        new_messages,
        max_nick_chars,
        max_prefix_chars,
        ..
    }) = history.get_messages(&kind.into(), Some(state.limit), &config.buffer)
    else {
        return column![].into();
    };

Hey! Hey! Look! column![] is here! I think we're on the right track!

Ok... Ok, I see the top bar here and right underneath that is the messages! There's a whole bunch of... stuff happening. And then after all that stuff the content is stuffed into the scroll.

let content = column![]
    .push_maybe(top_row)
    .push(column(old))
    .push(keyed(keyed::Key::Divider, divider))
    .push(column(new));

correct_viewport(
    Scrollable::new(container(content).width(Length::Fill).padding([0, 8]))
        .direction(scrollable::Direction::Vertical(
            scrollable::Scrollbar::default()
                .anchor(status.anchor())
                .width(5)
                .scroller_width(5),
        ))
        .on_scroll(move |viewport| Message::Scrolled {
            has_more_older_messages,
            has_more_newer_messages,
            count,
            oldest,
            status,
            viewport,
        })
        .id(state.scrollable.clone()),
    state.scrollable.clone(),
    matches!(state.status, Status::Unlocked),
)

Of course, the key thing I'm noting here is the fact that messages aren't just one big list. The divider that appears if you've got new messages you haven't seen yet separates the two. But I think I'm in the right area at least. Scrolling back up and searching through, I spotted this code:

let old = message_rows(None, &old_messages);
let new = message_rows(
    old_messages.last().map(|message| {
        message.server_time.with_timezone(&Local).date_naive()
    }),
    &new_messages,
);

And so that led me over to the message_rows closure. Which I think is what's actually calling the message formatter I saw earlier. After some study, I noted that the case where a URL preview is created does this:

let mut column = column![element];

for (idx, url) in urls.into_iter().enumerate() {
    if message.hidden_urls.contains(&url) {
        continue;
    }

    if let (
        true,
        Some(preview::State::Loaded(preview)),
    ) = (is_message_visible, previews.get(&url))
    {
        let is_hovered =
            state.hovered_preview.is_some_and(
                |(a, b)| a == message.hash && b == idx,
            );

        column = column.push(preview_row(
            message,
            preview,
            &url,
            idx,
            max_nick_width,
            max_prefix_width,
            is_hovered,
            config,
        ));
    }
}

column.into()

Remember the documentation from iced before? On how columns can have padding and spacing and all that code stuff? Well, it looks like any of the message "elements" can be trivially put into a column to do additional tweaking and spacing! This snippet is inside of an if statement that pulls out the message content if it exists, and has two else arms that simply return the element:

let content = if let (
    message::Content::Fragments(fragments),
    Some(previews),
    true,
) =
    (&message.content, previews, config.preview.enabled)
{
    let urls = fragments
        .iter()
        .filter_map(message::Fragment::url)
        .cloned()
        .collect::<Vec<_>>();

    if !urls.is_empty() {
        ...
        let mut column = column![element];
        ...
        column.into()
    } else {
        element
    }
} else {
    element
};

But... if you can do additional tweaking and forming of the element in one case, then we can do it in the other! If I change that element in each of the else arms to do this...

column![element].spacing(100).padding(30).into()

Then I get a beautiful sight:

Ok, beauty is in the eye of the beholder I know, but these massive obvious spaces are the result of our code change! And so now we can dial it back into something a bit more pleasant. After a bit of trial and error, I decided to sit down onto:

column![element].padding(2).into()

And bask in the glory of my personal open source contribution that no one will ever see except me.