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.

