feat: add A4 page option to Word Processor

This commit is contained in:
2026-04-12 18:48:06 +02:00
parent 01546806f0
commit a7499c57c9
4 changed files with 190 additions and 18 deletions
+4
View File
@@ -542,6 +542,8 @@ void wp_init_empty_document(WordProcessorState* wp) {
wp->line_spacing_dropdown_open = false; wp->line_spacing_dropdown_open = false;
wp->divider_flyout_open = false; wp->divider_flyout_open = false;
wp->special_char_flyout_open = false; wp->special_char_flyout_open = false;
wp->page_view_mode = false;
wp->pending_page_view_toggle = false;
wp->undo_count = 0; wp->undo_count = 0;
wp->undo_pos = 0; wp->undo_pos = 0;
@@ -1685,6 +1687,8 @@ static bool wp_deserialize_document(WordProcessorState* wp, const uint8_t* buf,
wp->divider_flyout_open = false; wp->divider_flyout_open = false;
wp->special_char_flyout_open = false; wp->special_char_flyout_open = false;
wp->show_pathbar = false; wp->show_pathbar = false;
wp->page_view_mode = false;
wp->pending_page_view_toggle = false;
return true; return true;
} }
+39 -5
View File
@@ -159,8 +159,13 @@ static void wp_insert_divider_choice(WordProcessorState* wp, uint8_t divider_typ
wp_history_checkpoint(wp); wp_history_checkpoint(wp);
} }
static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, int edit_y) { static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, int edit_y,
int page_off_x, int page_off_y) {
int click_y = local_y - edit_y + wp->scrollbar.scroll_offset; int click_y = local_y - edit_y + wp->scrollbar.scroll_offset;
// In page view mode, subtract the page y offset
if (wp->page_view_mode) {
click_y = local_y - page_off_y + wp->scrollbar.scroll_offset;
}
int target_line = wp->wrap_line_count - 1; int target_line = wp->wrap_line_count - 1;
for (int i = 0; i < wp->wrap_line_count; i++) { for (int i = 0; i < wp->wrap_line_count; i++) {
@@ -175,7 +180,12 @@ static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, in
if (wl->divider_type != PARA_DIVIDER_NONE) if (wl->divider_type != PARA_DIVIDER_NONE)
return wp_wrap_line_start(wp, target_line); return wp_wrap_line_start(wp, target_line);
int click_x = local_x - wl->x; // In page view mode, subtract the page x offset from click x
int click_x = local_x;
if (wp->page_view_mode) {
click_x = local_x - page_off_x;
}
click_x -= wl->x;
int ri = wl->run_idx; int ri = wl->run_idx;
int ro = wl->run_offset; int ro = wl->run_offset;
int chars_left = wl->char_count; int chars_left = wl->char_count;
@@ -226,6 +236,25 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) {
int edit_y = WP_TOOLBAR_H + (wp->show_pathbar ? WP_PATHBAR_H : 0); int edit_y = WP_TOOLBAR_H + (wp->show_pathbar ? WP_PATHBAR_H : 0);
int text_area_h = g_win_h - edit_y - WP_STATUS_H; int text_area_h = g_win_h - edit_y - WP_STATUS_H;
// Handle pending page view toggle first (from toolbar button click)
if (wp->pending_page_view_toggle) {
wp->page_view_mode = !wp->page_view_mode;
wp->pending_page_view_toggle = false;
wp->wrap_dirty = true;
}
// Compute page offsets for hit testing (same logic as wp_render)
int page_off_x = 0;
int page_off_y = edit_y;
int recompute_w = g_win_w;
if (wp->page_view_mode) {
page_off_x = (g_win_w - WP_A4_PAGE_W) / 2;
if (page_off_x < 0) page_off_x = 0;
page_off_y = edit_y + WP_PAGE_TOP_MARGIN;
// Use A4 page width for wrap computation (to get proper margins)
recompute_w = WP_A4_PAGE_W;
}
wp->scrollbar.handle_mouse(local_x, local_y, ev.mouse.buttons, ev.mouse.prev_buttons, ev.mouse.scroll); wp->scrollbar.handle_mouse(local_x, local_y, ev.mouse.buttons, ev.mouse.prev_buttons, ev.mouse.scroll);
if (wp->font_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { if (wp->font_dropdown_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) {
@@ -413,6 +442,11 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) {
wp->special_char_flyout_open = open; wp->special_char_flyout_open = open;
return; return;
} }
if (local_x >= WP_BTN_PAGEVIEW_X && local_x < WP_BTN_PAGEVIEW_X + 24 && local_y >= 6 && local_y < 30) {
wp_close_dropdowns(wp);
wp->pending_page_view_toggle = true;
return;
}
return; return;
} }
@@ -428,13 +462,13 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) {
return; return;
} }
wp_recompute_wrap(wp, g_win_w, WP_SCREEN_DPI); wp_recompute_wrap(wp, recompute_w, WP_SCREEN_DPI);
if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) &&
local_y >= edit_y && local_y < edit_y + text_area_h) { local_y >= edit_y && local_y < edit_y + text_area_h) {
wp_close_dropdowns(wp); wp_close_dropdowns(wp);
int abs = wp_hit_test_text(wp, local_x, local_y, edit_y); int abs = wp_hit_test_text(wp, local_x, local_y, edit_y, page_off_x, page_off_y);
wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset);
wp->sel_anchor = abs; wp->sel_anchor = abs;
wp->sel_end = abs; wp->sel_end = abs;
@@ -444,7 +478,7 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) {
} }
if (wp->mouse_selecting && wp_left_held(ev.mouse.buttons) && local_y >= edit_y - 20) { if (wp->mouse_selecting && wp_left_held(ev.mouse.buttons) && local_y >= edit_y - 20) {
int abs = wp_hit_test_text(wp, local_x, local_y, edit_y); int abs = wp_hit_test_text(wp, local_x, local_y, edit_y, page_off_x, page_off_y);
wp->sel_end = abs; wp->sel_end = abs;
wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset); wp_pos_to_run(wp, abs, &wp->cursor_run, &wp->cursor_offset);
wp->has_selection = (wp->sel_anchor != wp->sel_end); wp->has_selection = (wp->sel_anchor != wp->sel_end);
+133 -13
View File
@@ -152,15 +152,15 @@ static const char* wp_divider_label(uint8_t divider_type) {
} }
} }
static void wp_draw_list_marker(Canvas& c, WordProcessorState* wp, WrapLine* wl, int py) { static void wp_draw_list_marker(Canvas& c, WordProcessorState* wp, WrapLine* wl, int py, int page_off_x) {
if (!wl->first_in_paragraph || wl->paragraph_idx < 0 || wl->paragraph_idx >= wp->paragraph_count) if (!wl->first_in_paragraph || wl->paragraph_idx < 0 || wl->paragraph_idx >= wp->paragraph_count)
return; return;
ParagraphStyle* para = &wp->paragraphs[wl->paragraph_idx]; ParagraphStyle* para = &wp->paragraphs[wl->paragraph_idx];
if (para->divider_type != PARA_DIVIDER_NONE || para->list_type == PARA_LIST_NONE) return; if (para->divider_type != PARA_DIVIDER_NONE || para->list_type == PARA_LIST_NONE) return;
int marker_x = WP_MARGIN + wp_scale_layout_units(para->left_indent + para->first_line_indent, WP_SCREEN_DPI); int marker_x = page_off_x + WP_MARGIN + wp_scale_layout_units(para->left_indent + para->first_line_indent, WP_SCREEN_DPI);
if (marker_x < 4) marker_x = 4; if (marker_x < page_off_x + 4) marker_x = page_off_x + 4;
if (para->list_type == PARA_LIST_BULLET) { if (para->list_type == PARA_LIST_BULLET) {
int bullet_x = marker_x + wp_scale_layout_units(7, WP_SCREEN_DPI); int bullet_x = marker_x + wp_scale_layout_units(7, WP_SCREEN_DPI);
@@ -293,6 +293,22 @@ void wp_render() {
draw_text(c, font ? font : g_ui_font, WP_BTN_SECTION_X + 6, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE); draw_text(c, font ? font : g_ui_font, WP_BTN_SECTION_X + 6, 8, section, colors::TEXT_COLOR, fonts::UI_SIZE);
} }
c.vline(WP_BTN_PAGEVIEW_X - 4, 4, 28, colors::BORDER);
Color pageview_bg = wp->page_view_mode ? btn_active : btn_bg;
c.fill_rounded_rect(WP_BTN_PAGEVIEW_X, 6, 24, 24, 3, pageview_bg);
{
// Draw a small page icon: rectangle with lines
int px = WP_BTN_PAGEVIEW_X + 4;
int py = 9;
// Page outline
c.rect(px, py, 14, 18, colors::TEXT_COLOR);
// Lines inside page
c.hline(px + 2, py + 4, 10, colors::TEXT_COLOR);
c.hline(px + 2, py + 8, 10, colors::TEXT_COLOR);
c.hline(px + 2, py + 12, 6, colors::TEXT_COLOR);
}
c.hline(0, WP_TOOLBAR_H - 1, c.w, colors::BORDER); c.hline(0, WP_TOOLBAR_H - 1, c.w, colors::BORDER);
int edit_y = WP_TOOLBAR_H; int edit_y = WP_TOOLBAR_H;
@@ -330,12 +346,40 @@ void wp_render() {
} }
int text_area_h = c.h - edit_y - WP_STATUS_H; int text_area_h = c.h - edit_y - WP_STATUS_H;
wp_recompute_wrap(wp, c.w, WP_SCREEN_DPI);
// Handle pending page view toggle
if (wp->pending_page_view_toggle) {
wp->page_view_mode = !wp->page_view_mode;
wp->pending_page_view_toggle = false;
wp->wrap_dirty = true;
}
// Page view mode: compute A4 page position and use A4 content width
int page_x = 0;
int page_y = edit_y;
int page_content_w = c.w;
bool in_page_view = wp->page_view_mode;
if (in_page_view) {
// Center A4 page horizontally in the window
page_x = (c.w - WP_A4_PAGE_W) / 2;
if (page_x < 0) page_x = 0;
// Page top with margin from edit area top
page_y = edit_y + WP_PAGE_TOP_MARGIN;
// Use full A4 page width for wrap computation to get proper A4 margins
// (WP_MARGIN is used for left margin, right margin comes from WP_A4_PAGE_W)
page_content_w = WP_A4_PAGE_W;
}
wp_recompute_wrap(wp, page_content_w, WP_SCREEN_DPI);
wp->scrollbar.bounds = {c.w - WP_SCROLLBAR_W, edit_y, WP_SCROLLBAR_W, text_area_h}; wp->scrollbar.bounds = {c.w - WP_SCROLLBAR_W, edit_y, WP_SCROLLBAR_W, text_area_h};
wp->scrollbar.content_height = wp->content_height; wp->scrollbar.content_height = wp->content_height;
wp->scrollbar.view_height = text_area_h; wp->scrollbar.view_height = text_area_h;
// In page view mode, keep scrollbar at right edge but use text_area_h for proper thumb sizing
// The scrollbar position doesn't change - it stays at c.w - WP_SCROLLBAR_W
// But the content scrolls within the A4 page view
{ {
int ms = wp->scrollbar.max_scroll(); int ms = wp->scrollbar.max_scroll();
if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms; if (wp->scrollbar.scroll_offset > ms) wp->scrollbar.scroll_offset = ms;
@@ -350,21 +394,53 @@ void wp_render() {
if (wp->has_selection) wp_sel_range(wp, &sel_s, &sel_e); if (wp->has_selection) wp_sel_range(wp, &sel_s, &sel_e);
Color sel_bg = Color::from_rgb(0xB0, 0xD0, 0xF0); Color sel_bg = Color::from_rgb(0xB0, 0xD0, 0xF0);
// In page view mode, draw the A4 page with shadow before text rendering
if (in_page_view) {
// Shadow strips around the page (4 sides + corners, drawn as solid dark rectangles)
int so = WP_PAGE_SHADOW_OFFSET;
Color shadow_col = Color::from_rgb(0x50, 0x50, 0x50);
// Top shadow (above page, full width)
c.fill_rect(page_x - so, page_y - so, WP_A4_PAGE_W + so * 2, so, shadow_col);
// Bottom shadow
c.fill_rect(page_x - so, page_y + WP_A4_PAGE_H, WP_A4_PAGE_W + so * 2, so, shadow_col);
// Left shadow (vertical strip, excluding corners already covered)
c.fill_rect(page_x - so, page_y, so, WP_A4_PAGE_H, shadow_col);
// Right shadow (vertical strip, excluding corners)
c.fill_rect(page_x + WP_A4_PAGE_W, page_y, so, WP_A4_PAGE_H, shadow_col);
// Corner pieces (top-left, top-right, bottom-left, bottom-right)
c.fill_rect(page_x - so, page_y - so, so, so, shadow_col);
c.fill_rect(page_x + WP_A4_PAGE_W, page_y - so, so, so, shadow_col);
c.fill_rect(page_x - so, page_y + WP_A4_PAGE_H, so, so, shadow_col);
c.fill_rect(page_x + WP_A4_PAGE_W, page_y + WP_A4_PAGE_H, so, so, shadow_col);
// White page background
c.fill_rect(page_x, page_y, WP_A4_PAGE_W, WP_A4_PAGE_H, colors::WHITE);
}
for (int li = 0; li < wp->wrap_line_count; li++) { for (int li = 0; li < wp->wrap_line_count; li++) {
WrapLine* wl = &wp->wrap_lines[li]; WrapLine* wl = &wp->wrap_lines[li];
int py = edit_y + wl->y - scroll_y; int py = edit_y + wl->y - scroll_y;
// In page view mode, offset y by page_y
if (in_page_view) {
py = page_y + wl->y - scroll_y;
}
if (py + wl->height <= edit_y) continue; if (py + wl->height <= edit_y) continue;
if (py >= edit_y + text_area_h) break; if (py >= edit_y + text_area_h) break;
wp_draw_list_marker(c, wp, wl, py); // In page view mode, offset x by page_x for all drawing
int draw_x = wl->x;
if (in_page_view) {
draw_x = page_x + wl->x;
}
wp_draw_list_marker(c, wp, wl, py, in_page_view ? page_x : 0);
if (wl->divider_type != PARA_DIVIDER_NONE) if (wl->divider_type != PARA_DIVIDER_NONE)
wp_draw_divider_sample(c, wl->x, py, wl->width, wl->height, wl->divider_type, colors::TEXT_COLOR); wp_draw_divider_sample(c, draw_x, py, wl->width, wl->height, wl->divider_type, colors::TEXT_COLOR);
int chars_left = wl->char_count; int chars_left = wl->char_count;
int ri = wl->run_idx; int ri = wl->run_idx;
int ro = wl->run_offset; int ro = wl->run_offset;
int x = wl->x; int x = draw_x;
int line_abs_start = wp_wrap_line_start(wp, li); int line_abs_start = wp_wrap_line_start(wp, li);
int char_idx = 0; int char_idx = 0;
@@ -395,22 +471,56 @@ void wp_render() {
if (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) { if (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) {
int sel_w = char_adv > 0 ? char_adv : 6; int sel_w = char_adv > 0 ? char_adv : 6;
c.fill_rect(x, py, sel_w, wl->height, sel_bg); // Clip selection to page bounds in page view mode
int sel_x = x;
int sel_y = py;
int sel_h = wl->height;
if (in_page_view) {
sel_x = gui_max(x, page_x);
int right = gui_min(x + sel_w, page_x + WP_A4_PAGE_W);
sel_w = right - sel_x;
sel_y = gui_max(py, page_y);
int bot = gui_min(py + sel_h, page_y + WP_A4_PAGE_H);
sel_h = bot - sel_y;
if (sel_w <= 0 || sel_h <= 0) sel_w = 0;
}
if (sel_w > 0)
c.fill_rect(sel_x, sel_y, sel_w, sel_h, sel_bg);
} }
if (abs_ch == cursor_abs) { if (abs_ch == cursor_abs) {
int cur_h = wl->height; int cur_h = wl->height;
if (py >= edit_y && py + cur_h <= edit_y + text_area_h) int cur_x = x;
c.fill_rect(x, py, 2, cur_h, colors::ACCENT); int cur_y = py;
int cur_w = 2;
if (in_page_view) {
// Clip cursor to page bounds
if (cur_x < page_x) { cur_w = cur_x + cur_w - page_x; cur_x = page_x; }
if (cur_x + cur_w > page_x + WP_A4_PAGE_W) cur_w = page_x + WP_A4_PAGE_W - cur_x;
cur_y = gui_max(cur_y, page_y);
int bot = gui_min(cur_y + cur_h, page_y + WP_A4_PAGE_H);
cur_h = bot - cur_y;
if (cur_h <= 0 || cur_w <= 0) cur_x = -1; // hide
}
if (cur_x >= 0 && py >= edit_y && py + cur_h <= edit_y + text_area_h)
c.fill_rect(cur_x, cur_y, cur_w, cur_h, colors::ACCENT);
} }
if (ch != '\n' && (ch >= 32 || ch < 0)) { if (ch != '\n' && (ch >= 32 || ch < 0)) {
if (x < c.w - WP_SCROLLBAR_W - WP_MARGIN) { // In page view mode, clip to page bounds
bool can_draw = (x < c.w - WP_SCROLLBAR_W - WP_MARGIN);
if (in_page_view) {
can_draw = (x + char_adv > page_x && x < page_x + WP_A4_PAGE_W &&
py + wl->height > page_y && py < page_y + WP_A4_PAGE_H);
}
if (can_draw) {
Color text_col = (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e) Color text_col = (wp->has_selection && abs_ch >= sel_s && abs_ch < sel_e)
? Color::from_rgb(0x10, 0x10, 0x10) : colors::TEXT_COLOR; ? Color::from_rgb(0x10, 0x10, 0x10) : colors::TEXT_COLOR;
int adv = font->draw_char_to_buffer( int adv = font->draw_char_to_buffer(
c.pixels, c.w, c.h, x, baseline, (unsigned char)ch, text_col, gc); c.pixels, c.w, c.h, x, baseline, (unsigned char)ch, text_col, gc);
x += adv; x += adv;
} else {
x += char_adv;
} }
} }
@@ -427,8 +537,18 @@ void wp_render() {
if (line_abs_start + char_idx == cursor_abs && li == wp->wrap_line_count - 1) { if (line_abs_start + char_idx == cursor_abs && li == wp->wrap_line_count - 1) {
int cur_h = wl->height; int cur_h = wl->height;
if (py >= edit_y && py + cur_h <= edit_y + text_area_h) int cur_x = x;
c.fill_rect(x, py, 2, cur_h, colors::ACCENT); int cur_y = py;
if (in_page_view) {
if (cur_x < page_x) cur_x = page_x;
if (cur_x > page_x + WP_A4_PAGE_W) cur_x = -1;
cur_y = gui_max(cur_y, page_y);
int bot = gui_min(cur_y + cur_h, page_y + WP_A4_PAGE_H);
cur_h = bot - cur_y;
if (cur_h <= 0) cur_x = -1;
}
if (cur_x >= 0 && py >= edit_y && py + cur_h <= edit_y + text_area_h)
c.fill_rect(cur_x, cur_y, 2, cur_h, colors::ACCENT);
} }
} }
@@ -28,6 +28,17 @@ static constexpr int WP_PATHBAR_H = 32;
static constexpr int WP_STATUS_H = 24; static constexpr int WP_STATUS_H = 24;
static constexpr int WP_SCROLLBAR_W = 12; static constexpr int WP_SCROLLBAR_W = 12;
static constexpr int WP_MARGIN = 16; static constexpr int WP_MARGIN = 16;
// A4 page dimensions at 96 DPI screen resolution (210mm x 297mm = 8.27" x 11.69")
static constexpr int WP_A4_PAGE_W = 794; // 8.27" * 96 DPI
static constexpr int WP_A4_PAGE_H = 1122; // 11.69" * 96 DPI
static constexpr int WP_A4_MARGIN_X = 96; // 1 inch = 96px margins (same as print)
static constexpr int WP_A4_MARGIN_Y = 96;
// Content width inside A4 page (794 - 96*2 - 16*2 = 570... but we use WP_A4 margins internally)
// A4 text area = WP_A4_PAGE_W - WP_A4_MARGIN_X * 2 = 794 - 192 = 602
static constexpr int WP_A4_CONTENT_W = 602;
// Vertical page offset from top of edit area (page top at edit_y + WP_PAGE_TOP_MARGIN)
static constexpr int WP_PAGE_TOP_MARGIN = 20;
static constexpr int WP_PAGE_SHADOW_OFFSET = 6;
static constexpr int WP_MAX_RUNS = 1024; static constexpr int WP_MAX_RUNS = 1024;
static constexpr int WP_MAX_TEXT = 262144; static constexpr int WP_MAX_TEXT = 262144;
static constexpr int WP_MAX_WRAP_LINES = 4096; static constexpr int WP_MAX_WRAP_LINES = 4096;
@@ -64,6 +75,7 @@ static constexpr int WP_LINE_DD_X = 586;
static constexpr int WP_LINE_DD_W = 48; static constexpr int WP_LINE_DD_W = 48;
static constexpr int WP_BTN_DIVIDER_X = 642; static constexpr int WP_BTN_DIVIDER_X = 642;
static constexpr int WP_BTN_SECTION_X = 674; static constexpr int WP_BTN_SECTION_X = 674;
static constexpr int WP_BTN_PAGEVIEW_X = 712;
static constexpr int WP_DIVIDER_ROW_H = 24; static constexpr int WP_DIVIDER_ROW_H = 24;
static constexpr int WP_DIVIDER_FLYOUT_W = 216; static constexpr int WP_DIVIDER_FLYOUT_W = 216;
static constexpr int WP_SPECIAL_CHAR_ROW_H = 24; static constexpr int WP_SPECIAL_CHAR_ROW_H = 24;
@@ -358,6 +370,8 @@ struct WordProcessorState {
bool line_spacing_dropdown_open; bool line_spacing_dropdown_open;
bool divider_flyout_open; bool divider_flyout_open;
bool special_char_flyout_open; bool special_char_flyout_open;
bool page_view_mode;
bool pending_page_view_toggle;
UndoSnapshot undo[WP_UNDO_MAX]; UndoSnapshot undo[WP_UNDO_MAX];
int undo_count; int undo_count;