Skip to content

Commit 508c758

Browse files
mkruegerCopilot
andcommitted
Add modern mail ANSI image zoom and rendering toggle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1361d3b commit 508c758

10 files changed

Lines changed: 427 additions & 59 deletions

File tree

‎crates/icy_mail/README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,15 @@ draft and composer previews still render locally.
165165
search match inside opens them). Tables
166166
and lines with aligned columns use the fixed-width font. ANSI art is drawn
167167
with the BBS font on the 80 column grid, exactly as in the classic view, at
168-
the size of the fixed-width text: a paragraph with block graphics (▀ ▄ █ ░ ▒
168+
the size of the fixed-width text at 100%. The status-bar picker, View menu and
169+
settings offer 100%, 150% and 200% image zoom without changing the text size.
170+
Oversized art scrolls horizontally instead of shrinking to fit; Fit Width
171+
remains available only in classic mode. Turn off **Render ANSI art as images**
172+
in Settings ▸ Messages to show art as selectable Unicode characters, preserving
173+
its fixed-width grid and ANSI colors instead of generating pictures. Image
174+
generation is enabled by default; the choice also applies to bulletins and
175+
outbox previews. Image zoom controls are hidden when image generation is off.
176+
A paragraph with block graphics (▀ ▄ █ ░ ▒
169177
▓ …) or colored backgrounds becomes a picture (bullets like ■ or ► stay text), together with short colored or aligned
170178
paragraphs up to the next piece of art (like a BBS ad); the next plain text
171179
paragraph switches back to text. Only text lines the terminal wrapped are

‎crates/icy_mail/i18n/de/icy_mail.ftl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ settings-modern-font-label = Schrift
213213
settings-modern-font-proportional = Proportional
214214
settings-modern-font-monospace = Feste Breite
215215
settings-modern-font-size-label = Schriftgröße
216+
settings-modern-art-images = ANSI-Grafiken als Bilder darstellen
216217
settings-zoom-label = Zoom
217218
settings-reading-pane-label = Lesebereich
218219
settings-reading-pane-automatic = Automatisch

‎crates/icy_mail/i18n/en/icy_mail.ftl‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ settings-modern-font-label = Font
213213
settings-modern-font-proportional = Proportional
214214
settings-modern-font-monospace = Fixed width
215215
settings-modern-font-size-label = Font size
216+
settings-modern-art-images = Render ANSI art as images
216217
settings-zoom-label = Zoom
217218
settings-reading-pane-label = Reading pane
218219
settings-reading-pane-automatic = Automatic

‎crates/icy_mail/src/bin/icy_mail_egui/app.rs‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ pub struct MailApp {
164164
pub reading_mode: ReadingMode,
165165
pub modern_font: ModernFont,
166166
pub modern_font_size: f32,
167+
pub modern_art_images: bool,
167168
pub extraction_cache_days: u32,
168169
/// The shown message as text and rendered art for the modern reading mode, built on first use
169170
/// and keyed by the packet and message they came from.
@@ -277,6 +278,7 @@ impl MailApp {
277278
reading_mode: options.reading_mode,
278279
modern_font: options.modern_font,
279280
modern_font_size: options.modern_font_size,
281+
modern_art_images: options.modern_art_images,
280282
extraction_cache_days: options.extraction_cache_days,
281283
modern_items: None,
282284
modern_layout_pending: false,
@@ -371,6 +373,7 @@ impl MailApp {
371373
if request.generation == self.loader.body_generation
372374
&& Some(request) == self.loader.body_request
373375
&& request.mode == self.reading_mode
376+
&& request.art_images == self.modern_art_images
374377
&& match request.source {
375378
BodySource::Message(index) => self.folder.holds_messages() && self.reader.selected_message == Some(index),
376379
BodySource::File(index, page) => {
@@ -617,7 +620,10 @@ impl MailApp {
617620
if self.rendered_file == Some(index)
618621
&& self.rendered_file_page == self.selected_file_page
619622
&& self.rendered_draft.is_none()
620-
&& self.loader.body_request.is_some_and(|request| request.mode == self.reading_mode)
623+
&& self
624+
.loader
625+
.body_request
626+
.is_some_and(|request| request.mode == self.reading_mode && request.art_images == self.modern_art_images)
621627
{
622628
return;
623629
}
@@ -632,14 +638,23 @@ impl MailApp {
632638
self.last_reader_click = None;
633639
self.reveal_message = true;
634640
self.body_loading = true;
635-
self.loader
636-
.body(package.clone(), BodySource::File(index, self.selected_file_page), self.reading_mode, context);
641+
self.loader.body(
642+
package.clone(),
643+
BodySource::File(index, self.selected_file_page),
644+
self.reading_mode,
645+
self.modern_art_images,
646+
context,
647+
);
637648
return;
638649
}
639650
if self.rendered == self.reader.selected_message
640651
&& self.rendered_draft.is_none()
641652
&& self.rendered_file.is_none()
642-
&& (self.reader.selected_message.is_none() || self.loader.body_request.is_some_and(|request| request.mode == self.reading_mode))
653+
&& (self.reader.selected_message.is_none()
654+
|| self
655+
.loader
656+
.body_request
657+
.is_some_and(|request| request.mode == self.reading_mode && request.art_images == self.modern_art_images))
643658
{
644659
return;
645660
}
@@ -652,7 +667,8 @@ impl MailApp {
652667
self.reveal_message = true;
653668
if let (Some(package), Some(index)) = (&self.reader.package, self.reader.selected_message) {
654669
self.body_loading = true;
655-
self.loader.body(package.clone(), BodySource::Message(index), self.reading_mode, context);
670+
self.loader
671+
.body(package.clone(), BodySource::Message(index), self.reading_mode, self.modern_art_images, context);
656672
} else {
657673
self.loader.cancel_preload();
658674
self.loader.body_request = None;

‎crates/icy_mail/src/bin/icy_mail_egui/chrome.rs‎

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -450,10 +450,12 @@ impl MailApp {
450450
}
451451
}
452452
});
453-
ui.menu_button(fl!(LANGUAGE_LOADER, "menu-message-zoom"), |ui| {
454-
menu_width(ui);
455-
zoom_choices(ui, &mut self.settings.scaling_mode);
456-
});
453+
if self.reading_mode == icy_mail::options::ReadingMode::Classic || self.modern_art_images || self.composer.is_some() {
454+
ui.menu_button(fl!(LANGUAGE_LOADER, "menu-message-zoom"), |ui| {
455+
menu_width(ui);
456+
zoom_choices(ui, &mut self.settings.scaling_mode, self.reading_mode);
457+
});
458+
}
457459
ui.menu_button(fl!(LANGUAGE_LOADER, "menu-appearance"), |ui| {
458460
menu_width(ui);
459461
let current = context.options(|options| options.theme_preference);
@@ -546,10 +548,12 @@ impl MailApp {
546548
if self.reader.package.is_none() {
547549
return;
548550
}
549-
let zoom = settings::zoom_name(self.settings.scaling_mode);
550-
widgets::status_menu(ui, egui::RichText::new(zoom).size(12.0), &fl!(LANGUAGE_LOADER, "status-message-zoom"), |ui| {
551-
zoom_choices(ui, &mut self.settings.scaling_mode);
552-
});
551+
if self.reading_mode == icy_mail::options::ReadingMode::Classic || self.modern_art_images {
552+
let zoom = settings::zoom_name(settings::display_zoom(self.settings.scaling_mode, self.reading_mode));
553+
widgets::status_menu(ui, egui::RichText::new(zoom).size(12.0), &fl!(LANGUAGE_LOADER, "status-message-zoom"), |ui| {
554+
zoom_choices(ui, &mut self.settings.scaling_mode, self.reading_mode);
555+
});
556+
}
553557
let drafts = self.draft_count();
554558
if drafts > 0 && ui.available_width() > 260.0 {
555559
ui.separator();
@@ -591,9 +595,15 @@ fn search_hint(fields: SearchFields) -> String {
591595
fl!(LANGUAGE_LOADER, "toolbar-search-hint-fields", fields = names.join(", "))
592596
}
593597

594-
fn zoom_choices(ui: &mut egui::Ui, mode: &mut ScalingMode) {
595-
for zoom in settings::ZOOMS {
596-
if ui.add(egui::Button::selectable(*mode == zoom, settings::zoom_name(zoom))).clicked() {
598+
fn zoom_choices(ui: &mut egui::Ui, mode: &mut ScalingMode, reading_mode: icy_mail::options::ReadingMode) {
599+
for &zoom in settings::zoom_modes(reading_mode) {
600+
if ui
601+
.add(egui::Button::selectable(
602+
settings::display_zoom(*mode, reading_mode) == zoom,
603+
settings::zoom_name(zoom),
604+
))
605+
.clicked()
606+
{
597607
*mode = zoom;
598608
ui.close();
599609
}

‎crates/icy_mail/src/bin/icy_mail_egui/loading.rs‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,15 @@ pub struct BodyRequest {
4747
pub generation: u64,
4848
pub source: BodySource,
4949
pub mode: ReadingMode,
50+
pub art_images: bool,
5051
}
5152

5253
pub struct PreparedBody {
5354
pub classic: TextScreen,
5455
pub modern: Option<Vec<PreparedItem>>,
5556
}
5657

57-
fn prepare_body(package: &QwkPackage, source: BodySource, mode: ReadingMode) -> icy_mail::Res<PreparedBody> {
58+
fn prepare_body(package: &QwkPackage, source: BodySource, mode: ReadingMode, art_images: bool) -> icy_mail::Res<PreparedBody> {
5859
let (classic, wide) = match source {
5960
BodySource::Message(index) => {
6061
let message = package.get_message(index)?;
@@ -69,7 +70,7 @@ fn prepare_body(package: &QwkPackage, source: BodySource, mode: ReadingMode) ->
6970
(classic, wide)
7071
}
7172
};
72-
let modern = wide.map(|wide| modern_view::prepare_items(&classic, modern_view::blocks(&classic, &wide)));
73+
let modern = wide.map(|wide| modern_view::prepare_items(&classic, modern_view::blocks(&classic, &wide), art_images));
7374
Ok(PreparedBody { classic, modern })
7475
}
7576

@@ -157,7 +158,7 @@ impl Default for Loader {
157158
Some(Event::Package(generation, path, result))
158159
});
159160
let bodies = latest_worker(sender.clone(), |(request, package): (BodyRequest, Arc<QwkPackage>)| {
160-
let result = prepare_body(&package, request.source, request.mode).map_err(|error| error.to_string());
161+
let result = prepare_body(&package, request.source, request.mode, request.art_images).map_err(|error| error.to_string());
161162
Some(Event::Body(request, result))
162163
});
163164
let preload_generation = Arc::new(AtomicU64::new(0));
@@ -233,13 +234,14 @@ impl Loader {
233234
.send(((self.package_generation, path, cache_directory, retention_days), context.clone()));
234235
}
235236

236-
pub fn body(&mut self, package: Arc<QwkPackage>, source: BodySource, mode: ReadingMode, context: &egui::Context) {
237+
pub fn body(&mut self, package: Arc<QwkPackage>, source: BodySource, mode: ReadingMode, art_images: bool, context: &egui::Context) {
237238
self.cancel_preload();
238239
self.body_generation = self.body_generation.wrapping_add(1);
239240
let request = BodyRequest {
240241
generation: self.body_generation,
241242
source,
242243
mode,
244+
art_images,
243245
};
244246
self.body_request = Some(request);
245247
if let Err(error) = self.bodies.send(((request, package), context.clone())) {
@@ -446,6 +448,24 @@ fn latest_worker<T: Send + 'static>(events: mpsc::Sender<Event>, mut load: impl
446448
mod tests {
447449
use super::*;
448450

451+
#[test]
452+
fn body_worker_prepares_unicode_art_without_generating_images() {
453+
let (_dir, mut package) = crate::packet_tests::load();
454+
Arc::make_mut(&mut package.files)[0].data = b"\x1b[31m\xdb\xdb\xdb\r\n\xdb\xdb\xdb\x1b[0m".to_vec();
455+
let context = egui::Context::default();
456+
let mut loader = Loader::default();
457+
loader.body(Arc::new(package), BodySource::File(0, 0), ReadingMode::Modern, false, &context);
458+
let Event::Body(request, Ok(prepared)) = loader.receiver.recv_timeout(std::time::Duration::from_secs(10)).unwrap() else {
459+
panic!("body worker failed to prepare Unicode art");
460+
};
461+
assert!(!request.art_images);
462+
let items = prepared.modern.unwrap();
463+
assert!(items.iter().all(|item| matches!(item, PreparedItem::Text(_))));
464+
assert!(items
465+
.iter()
466+
.any(|item| matches!(item, PreparedItem::Text(line) if line.unwrapped && line.spans.iter().any(|span| span.text.contains('\u{2588}')))));
467+
}
468+
449469
#[test]
450470
fn body_worker_prepares_modern_message_and_bulletin_content() {
451471
let (_dir, mut package) = crate::packet_tests::load();
@@ -459,7 +479,7 @@ mod tests {
459479
for source in [BodySource::Message(2), BodySource::File(0, 0)] {
460480
for mode in [ReadingMode::Classic, ReadingMode::Modern] {
461481
let previous_preload = loader.preload_generation();
462-
loader.body(package.clone(), source, mode, &context);
482+
loader.body(package.clone(), source, mode, true, &context);
463483
assert_ne!(
464484
loader.preload_generation(),
465485
previous_preload,
@@ -477,7 +497,7 @@ mod tests {
477497
BodySource::Message(index) => render_body_wide(&package.get_message(index).unwrap().text).unwrap(),
478498
BodySource::File(index, page) => render_file_page_wide(&package.files[index].data, page).unwrap(),
479499
};
480-
let expected = modern_view::prepare_items(&prepared.classic, modern_view::blocks(&prepared.classic, &wide));
500+
let expected = modern_view::prepare_items(&prepared.classic, modern_view::blocks(&prepared.classic, &wide), true);
481501
assert_eq!(items.len(), expected.len());
482502
for (actual, expected) in items.iter().zip(&expected) {
483503
match (actual, expected) {

0 commit comments

Comments
 (0)