From 516b53281e638424c1375ade80f8529f9d336049 Mon Sep 17 00:00:00 2001 From: Daniel Hammer Date: Thu, 2 Apr 2026 11:30:37 +0200 Subject: [PATCH] wip: printing from word processor, word processor improvements --- kernel/src/Api/WinServer.cpp | 12 + programs/include/gui/dialogs.hpp | 51 +++ programs/include/print/print.hpp | 264 ++++++++++- programs/src/dialogs/main.cpp | 382 +++++++++++++--- programs/src/printctl/main.cpp | 1 + programs/src/printd/main.cpp | 154 +++++-- programs/src/wordprocessor/Makefile | 8 +- programs/src/wordprocessor/document.cpp | 5 + programs/src/wordprocessor/input.cpp | 51 ++- programs/src/wordprocessor/main.cpp | 4 + programs/src/wordprocessor/print_export.cpp | 433 +++++++++++++++++++ programs/src/wordprocessor/render.cpp | 74 +++- programs/src/wordprocessor/wordprocessor.hpp | 66 ++- 13 files changed, 1379 insertions(+), 126 deletions(-) create mode 100644 programs/src/wordprocessor/print_export.cpp diff --git a/kernel/src/Api/WinServer.cpp b/kernel/src/Api/WinServer.cpp index 0cd7275..aa9b287 100644 --- a/kernel/src/Api/WinServer.cpp +++ b/kernel/src/Api/WinServer.cpp @@ -16,6 +16,7 @@ namespace WinServer { static int g_uiScale = 1; static kcp::Mutex wsLock; static constexpr int MaxRetiredSnapshots = MaxWindows * 4; + static constexpr uint32_t InitialWindowPixel = 0xFFFFFFFFu; struct RetiredSnapshot { bool used; @@ -34,6 +35,11 @@ namespace WinServer { memset(&slot, 0, sizeof(WindowSlot)); } + static void FillSurfaceLocked(Ipc::Surface* surface, int width, int height, uint32_t pixel) { + if (surface == nullptr || width <= 0 || height <= 0) return; + Ipc::CopySurfacePreserve(surface, width, height, surface, width, height, pixel); + } + static void RetireSnapshotLocked(int windowId, Ipc::Surface* surface) { if (surface == nullptr) return; @@ -127,6 +133,12 @@ namespace WinServer { Ipc::RetainSurface(snapshot); Ipc::RetainMailbox(mailbox, true, true); + // New app windows become visible to the compositor before the app has + // necessarily rendered its first frame. Seed both buffers with the + // normal white document background so we don't flash an all-zero frame. + FillSurfaceLocked(live, w, h, InitialWindowPixel); + FillSurfaceLocked(snapshot, w, h, InitialWindowPixel); + uint64_t userVa = 0; if (Ipc::MapSurfaceForPid(live, ownerPid, ownerPml4, heapNext, userVa) != 0) { Ipc::ReleaseMailbox(mailbox, true, true); diff --git a/programs/include/gui/dialogs.hpp b/programs/include/gui/dialogs.hpp index 1b74343..beec643 100644 --- a/programs/include/gui/dialogs.hpp +++ b/programs/include/gui/dialogs.hpp @@ -27,6 +27,11 @@ enum FileDialogMode : uint8_t { FILE_DIALOG_SAVE = 2, }; +enum PrintDialogMode : uint8_t { + PRINT_DIALOG_SETUP = 1, + PRINT_DIALOG_SUBMIT = 2, +}; + struct Request { uint8_t kind; uint8_t mode; @@ -35,6 +40,8 @@ struct Request { char suggested_name[128]; char source_path[256]; char job_name[128]; + char printer_uri[256]; + uint32_t copies; char result_path[256]; }; @@ -42,6 +49,9 @@ struct Result { char status[16]; char path[256]; char job_id[64]; + char printer_uri[256]; + char printer_name[128]; + uint32_t copies; char message[160]; }; @@ -147,6 +157,8 @@ inline bool write_request_file(const char* path, const Request* req) { "suggested_name=%s\n" "source_path=%s\n" "job_name=%s\n" + "printer_uri=%s\n" + "copies=%u\n" "result_path=%s\n", (unsigned)req->kind, (unsigned)req->mode, @@ -155,6 +167,8 @@ inline bool write_request_file(const char* path, const Request* req) { req->suggested_name, req->source_path, req->job_name, + req->printer_uri, + (unsigned)req->copies, req->result_path); if (n <= 0 || n >= (int)sizeof(text)) return false; return write_text_file(path, text); @@ -174,6 +188,8 @@ inline bool read_request_file(const char* path, Request* req) { line_value(text, "suggested_name", req->suggested_name, sizeof(req->suggested_name)); line_value(text, "source_path", req->source_path, sizeof(req->source_path)); line_value(text, "job_name", req->job_name, sizeof(req->job_name)); + line_value(text, "printer_uri", req->printer_uri, sizeof(req->printer_uri)); + if (line_value(text, "copies", value, sizeof(value))) req->copies = parse_u32(value); line_value(text, "result_path", req->result_path, sizeof(req->result_path)); return req->kind != 0 && req->result_path[0] != '\0'; } @@ -185,10 +201,16 @@ inline bool write_result_file(const char* path, const Result* res) { "status=%s\n" "path=%s\n" "job_id=%s\n" + "printer_uri=%s\n" + "printer_name=%s\n" + "copies=%u\n" "message=%s\n", res->status, res->path, res->job_id, + res->printer_uri, + res->printer_name, + (unsigned)res->copies, res->message); if (n <= 0 || n >= (int)sizeof(text)) return false; return write_text_file(path, text); @@ -200,9 +222,13 @@ inline bool read_result_file(const char* path, Result* res) { if (!read_text_file(path, text, sizeof(text))) return false; reset_result(res); + char value[256]; line_value(text, "status", res->status, sizeof(res->status)); line_value(text, "path", res->path, sizeof(res->path)); line_value(text, "job_id", res->job_id, sizeof(res->job_id)); + line_value(text, "printer_uri", res->printer_uri, sizeof(res->printer_uri)); + line_value(text, "printer_name", res->printer_name, sizeof(res->printer_name)); + if (line_value(text, "copies", value, sizeof(value))) res->copies = parse_u32(value); line_value(text, "message", res->message, sizeof(res->message)); return res->status[0] != '\0'; } @@ -311,6 +337,8 @@ inline bool print_file(const char* title, char* out_message = nullptr, int out_message_len = 0) { Request req = {}; req.kind = REQUEST_KIND_PRINT; + req.mode = PRINT_DIALOG_SUBMIT; + req.copies = 1; safe_copy(req.title, sizeof(req.title), title ? title : "Print"); safe_copy(req.source_path, sizeof(req.source_path), source_path); safe_copy(req.job_name, sizeof(req.job_name), job_name); @@ -321,4 +349,27 @@ inline bool print_file(const char* title, return ok; } +inline bool configure_print(const char* title, + const char* initial_printer_uri, + const char* job_name, + char* out_printer_uri, int out_printer_uri_len, + char* out_printer_name, int out_printer_name_len, + uint32_t* out_copies = nullptr, + char* out_message = nullptr, int out_message_len = 0) { + Request req = {}; + req.kind = REQUEST_KIND_PRINT; + req.mode = PRINT_DIALOG_SETUP; + req.copies = out_copies && *out_copies > 0 ? *out_copies : 1; + safe_copy(req.title, sizeof(req.title), title ? title : "Print"); + safe_copy(req.job_name, sizeof(req.job_name), job_name ? job_name : ""); + safe_copy(req.printer_uri, sizeof(req.printer_uri), initial_printer_uri); + + Result res = {}; + bool ok = run_request(&req, &res, out_message, out_message_len); + if (ok && out_printer_uri && out_printer_uri_len > 0) safe_copy(out_printer_uri, out_printer_uri_len, res.printer_uri); + if (ok && out_printer_name && out_printer_name_len > 0) safe_copy(out_printer_name, out_printer_name_len, res.printer_name); + if (ok && out_copies) *out_copies = res.copies > 0 ? res.copies : 1; + return ok; +} + } // namespace gui::dialogs diff --git a/programs/include/print/print.hpp b/programs/include/print/print.hpp index e941610..52590f0 100644 --- a/programs/include/print/print.hpp +++ b/programs/include/print/print.hpp @@ -30,7 +30,9 @@ static constexpr const char* SPOOL_DONE_DIR = "0:/spool/print/done"; static constexpr const char* SPOOL_FAILED_DIR = "0:/spool/print/failed"; static constexpr const char* SPOOL_DOCS_DIR = "0:/spool/print/docs"; static constexpr const char* SPOOL_TMP_DIR = "0:/spool/print/tmp"; +static constexpr const char* SPOOL_PRINTERS_DIR = "0:/spool/print/printers"; static constexpr const char* SPOOL_DAEMON_PID_PATH = "0:/spool/print/daemon.pid"; +static constexpr const char* SPOOL_DAEMON_STATE_PATH = "0:/spool/print/daemon.state"; static constexpr const char* SPOOL_PROBE_STATE_PATH = "0:/spool/print/printer-probe.state"; static constexpr const char* PRINTD_BINARY = "0:/os/printd.elf"; static constexpr int MAX_PATH_LEN = 256; @@ -58,6 +60,7 @@ struct JobMeta { char debug_info[MAX_DEBUG_LEN]; int remote_job_id; int size_bytes; + int copies; }; struct IppUri { @@ -98,6 +101,15 @@ struct ProbeState { IppCapabilities caps; }; +struct KnownPrinter { + char uri[MAX_PATH_LEN]; + char display_name[128]; + char last_seen[32]; + char status_message[128]; + bool ok; + bool is_default; +}; + struct IppPrintResult { bool ok; bool use_tls; @@ -134,6 +146,10 @@ inline void zero_probe_state(ProbeState* state) { if (state) memset(state, 0, sizeof(*state)); } +inline void zero_known_printer(KnownPrinter* printer) { + if (printer) memset(printer, 0, sizeof(*printer)); +} + inline void safe_copy(char* dst, int dst_len, const char* src) { if (dst == nullptr || dst_len <= 0) return; if (src == nullptr) { @@ -207,7 +223,8 @@ inline bool ensure_spool_dirs() { && ensure_dir(SPOOL_DONE_DIR) && ensure_dir(SPOOL_FAILED_DIR) && ensure_dir(SPOOL_DOCS_DIR) - && ensure_dir(SPOOL_TMP_DIR); + && ensure_dir(SPOOL_TMP_DIR) + && ensure_dir(SPOOL_PRINTERS_DIR); } inline bool path_join(char* out, int out_len, const char* a, const char* b) { @@ -370,9 +387,136 @@ inline bool read_default_printer_uri(char* out, int out_len) { return read_text_file_line(DEFAULT_PRINTER_PATH, out, out_len); } +inline uint32_t hash_uri_key(const char* text) { + uint32_t hash = 2166136261u; + if (!text) return hash; + for (const unsigned char* p = (const unsigned char*)text; *p; ++p) { + hash ^= (uint32_t)*p; + hash *= 16777619u; + } + return hash; +} + +inline void make_known_printer_path(const char* uri, char* out, int out_len) { + unsigned hash = (unsigned)hash_uri_key(uri); + char leaf[32]; + snprintf(leaf, sizeof(leaf), "%08x.printer", hash); + path_join(out, out_len, SPOOL_PRINTERS_DIR, leaf); +} + +inline bool save_known_printer_atomic(const KnownPrinter* printer) { + if (printer == nullptr || printer->uri[0] == '\0') return false; + + char path[MAX_PATH_LEN]; + make_known_printer_path(printer->uri, path, sizeof(path)); + + char text[768]; + int n = snprintf(text, sizeof(text), + "uri=%s\n" + "display_name=%s\n" + "last_seen=%s\n" + "status_message=%s\n" + "ok=%d\n", + printer->uri, + printer->display_name, + printer->last_seen, + printer->status_message, + printer->ok ? 1 : 0); + if (n <= 0 || n >= (int)sizeof(text)) return false; + return write_text_file_atomic(path, text); +} + +inline bool load_known_printer_from_path(const char* path, KnownPrinter* printer) { + if (path == nullptr || printer == nullptr) return false; + zero_known_printer(printer); + + FILE* f = fopen(path, "rb"); + if (!f) return false; + + char line[384]; + while (fgets(line, sizeof(line), f) != nullptr) { + trim_line(line); + char* eq = strchr(line, '='); + if (!eq) continue; + *eq++ = '\0'; + + if (strcmp(line, "uri") == 0) safe_copy(printer->uri, sizeof(printer->uri), eq); + else if (strcmp(line, "display_name") == 0) safe_copy(printer->display_name, sizeof(printer->display_name), eq); + else if (strcmp(line, "last_seen") == 0) safe_copy(printer->last_seen, sizeof(printer->last_seen), eq); + else if (strcmp(line, "status_message") == 0) safe_copy(printer->status_message, sizeof(printer->status_message), eq); + else if (strcmp(line, "ok") == 0) printer->ok = atoi(eq) != 0; + } + fclose(f); + return printer->uri[0] != '\0'; +} + +inline bool remember_printer_uri(const char* uri, + const char* display_name, + bool ok, + const char* status_message, + const char* last_seen) { + if (uri == nullptr || *uri == '\0') return false; + ensure_spool_dirs(); + + KnownPrinter printer = {}; + safe_copy(printer.uri, sizeof(printer.uri), uri); + safe_copy(printer.display_name, sizeof(printer.display_name), display_name ? display_name : ""); + safe_copy(printer.last_seen, sizeof(printer.last_seen), last_seen ? last_seen : ""); + safe_copy(printer.status_message, sizeof(printer.status_message), status_message ? status_message : ""); + printer.ok = ok; + return save_known_printer_atomic(&printer); +} + +inline int list_known_printers(KnownPrinter* out, int max) { + if (out == nullptr || max <= 0) return 0; + ensure_spool_dirs(); + + int count = 0; + DIR* d = opendir(SPOOL_PRINTERS_DIR); + if (d) { + struct dirent* ent = nullptr; + while ((ent = readdir(d)) != nullptr && count < max) { + if (ent->d_name[0] == '.') continue; + char path[MAX_PATH_LEN]; + path_join(path, sizeof(path), SPOOL_PRINTERS_DIR, ent->d_name); + if (load_known_printer_from_path(path, &out[count])) + count++; + } + closedir(d); + } + + char default_uri[MAX_PATH_LEN] = {}; + bool have_default = read_default_printer_uri(default_uri, sizeof(default_uri)); + for (int i = 0; i < count; i++) { + out[i].is_default = have_default && strcmp(out[i].uri, default_uri) == 0; + } + + for (int i = 1; i < count; i++) { + KnownPrinter tmp = out[i]; + int j = i - 1; + while (j >= 0) { + bool move = false; + if (tmp.is_default && !out[j].is_default) move = true; + else if (tmp.is_default == out[j].is_default) { + const char* ta = tmp.display_name[0] ? tmp.display_name : tmp.uri; + const char* tb = out[j].display_name[0] ? out[j].display_name : out[j].uri; + if (strcmp(ta, tb) < 0) move = true; + } + if (!move) break; + out[j + 1] = out[j]; + j--; + } + out[j + 1] = tmp; + } + + return count; +} + inline bool write_default_printer_uri(const char* uri) { ensure_spool_dirs(); - return write_text_file_atomic(DEFAULT_PRINTER_PATH, uri); + bool ok = write_text_file_atomic(DEFAULT_PRINTER_PATH, uri); + if (ok && uri && *uri) remember_printer_uri(uri, nullptr, false, "", ""); + return ok; } inline bool parse_args(ArgList* out) { @@ -623,14 +767,14 @@ inline bool infer_doc_format(const char* path, char* out, int out_len) { } inline void generate_job_id(char* out, int out_len) { + static uint32_t seq = 0; uint64_t now = montauk::get_milliseconds(); - uint32_t rnd = 0; - if (montauk::getrandom(&rnd, sizeof(rnd)) < 0) { - rnd = (uint32_t)(now ^ (uint64_t)montauk::getpid()); - } - snprintf(out, (size_t)out_len, "%llu-%04x", + uint32_t cur = ++seq; + unsigned pid = (unsigned)(montauk::getpid() & 0xFFFF); + snprintf(out, (size_t)out_len, "%llu-%04x-%04x", (unsigned long long)now, - (unsigned)(rnd & 0xFFFFu)); + pid, + (unsigned)(cur & 0xFFFFu)); } inline bool save_job_to_path_atomic(const char* path, const JobMeta* job) { @@ -668,6 +812,7 @@ inline bool save_job_to_path_atomic(const char* path, const JobMeta* job) { fprintf(f, "debug_info=%s\n", copy.debug_info); fprintf(f, "remote_job_id=%d\n", copy.remote_job_id); fprintf(f, "size_bytes=%d\n", copy.size_bytes); + fprintf(f, "copies=%d\n", copy.copies); fclose(f); if (rename(tmp, path) != 0) { @@ -706,8 +851,10 @@ inline bool load_job_from_path(const char* path, JobMeta* job) { else if (strcmp(line, "debug_info") == 0) safe_copy(job->debug_info, sizeof(job->debug_info), eq); else if (strcmp(line, "remote_job_id") == 0) job->remote_job_id = atoi(eq); else if (strcmp(line, "size_bytes") == 0) job->size_bytes = atoi(eq); + else if (strcmp(line, "copies") == 0) job->copies = atoi(eq); } fclose(f); + if (job->copies <= 0) job->copies = 1; return job->id[0] != '\0'; } @@ -806,7 +953,14 @@ inline bool load_probe_state(const char* path, ProbeState* state) { inline bool save_printer_probe_state(const ProbeState* state) { ensure_spool_dirs(); - return save_probe_state_atomic(SPOOL_PROBE_STATE_PATH, state); + bool ok = save_probe_state_atomic(SPOOL_PROBE_STATE_PATH, state); + if (ok && state && state->printer_uri[0]) { + const char* name = state->caps.printer_name[0] ? state->caps.printer_name : nullptr; + const char* msg = state->message[0] ? state->message + : (state->caps.status_message[0] ? state->caps.status_message : ""); + remember_printer_uri(state->printer_uri, name, state->ok, msg, state->probed_at); + } + return ok; } inline bool load_printer_probe_state(ProbeState* state) { @@ -853,6 +1007,17 @@ inline bool daemon_is_running(int* out_pid) { uint32_t sig = montauk::wait_handle(handle, Montauk::IPC_SIGNAL_EXITED, 0); montauk::close(handle); if (sig & Montauk::IPC_SIGNAL_EXITED) return false; + + Montauk::ProcInfo procs[128]; + int proc_count = montauk::proclist(procs, (int)(sizeof(procs) / sizeof(procs[0]))); + bool matched = false; + for (int i = 0; i < proc_count; i++) { + if (procs[i].pid != pid) continue; + if (strcmp(procs[i].name, PRINTD_BINARY) == 0) matched = true; + break; + } + if (!matched) return false; + if (out_pid) *out_pid = pid; return true; } @@ -1709,6 +1874,7 @@ inline bool probe_printer_uri(const char* printer_uri, ProbeState* out, char* er inline bool ipp_print_buffer(const char* printer_uri, const char* job_name, const char* user_name, + int copies, const char* doc_format, const uint8_t* data, int data_len, IppPrintResult* out, @@ -1738,6 +1904,7 @@ inline bool ipp_print_buffer(const char* printer_uri, const char* use_job_name = (job_name && *job_name) ? job_name : "MontaukOS Print Job"; const char* use_user = (user_name && *user_name) ? user_name : "montauk"; const char* use_format = (doc_format && *doc_format) ? doc_format : "application/octet-stream"; + int use_copies = copies > 0 ? copies : 1; if (out) { out->request_id = (int)request_id; safe_copy(out->document_format, sizeof(out->document_format), use_format); @@ -1752,6 +1919,7 @@ inline bool ipp_print_buffer(const char* printer_uri, && bb_put_attr_str(&req, 0x45, "printer-uri", uri.normalized) && bb_put_attr_str(&req, 0x42, "requesting-user-name", use_user) && bb_put_attr_str(&req, 0x42, "job-name", use_job_name) + && bb_put_attr_int(&req, 0x21, "copies", use_copies) && bb_put_attr_str(&req, 0x49, "document-format", use_format) && bb_put8(&req, 0x03) && bb_put_bytes(&req, data, data_len); @@ -1802,7 +1970,8 @@ inline bool submit_document_job(const char* source_path, const char* printer_uri, const char* job_name_override, char* out_job_id, int out_job_id_len, - char* err, int err_len) { + char* err, int err_len, + int copies = 1) { ensure_spool_dirs(); char resolved_uri[MAX_PATH_LEN]; @@ -1843,6 +2012,77 @@ inline bool submit_document_job(const char* source_path, uri.normalized, job.doc_format, job.source_name); job.remote_job_id = 0; job.size_bytes = size_bytes; + job.copies = copies > 0 ? copies : 1; + + char job_path[MAX_PATH_LEN]; + make_job_file_path(SPOOL_QUEUE_DIR, job.id, job_path, sizeof(job_path)); + if (!save_job_to_path_atomic(job_path, &job)) { + remove(doc_path); + safe_copy(err, err_len, "failed to create queued job"); + return false; + } + + if (!ensure_daemon_running(err, err_len)) return false; + + safe_copy(out_job_id, out_job_id_len, job.id); + return true; +} + +inline bool submit_document_buffer_job(const uint8_t* data, int data_len, + const char* source_name, + const char* doc_format, + const char* printer_uri, + const char* job_name_override, + int copies = 1, + char* out_job_id = nullptr, int out_job_id_len = 0, + char* err = nullptr, int err_len = 0) { + ensure_spool_dirs(); + + if (data == nullptr || data_len < 0) { + safe_copy(err, err_len, "missing print document"); + return false; + } + + char resolved_uri[MAX_PATH_LEN]; + if (printer_uri && *printer_uri) safe_copy(resolved_uri, sizeof(resolved_uri), printer_uri); + else if (!read_default_printer_uri(resolved_uri, sizeof(resolved_uri))) { + safe_copy(err, err_len, "no default printer is configured"); + return false; + } + + IppUri uri = {}; + if (!parse_ipp_uri(resolved_uri, &uri, err, err_len)) return false; + + char job_id[48]; + generate_job_id(job_id, sizeof(job_id)); + + char doc_path[MAX_PATH_LEN]; + make_doc_file_path(job_id, doc_path, sizeof(doc_path)); + if (!write_file_atomic(doc_path, data, data_len, err, err_len)) return false; + + JobMeta job = {}; + safe_copy(job.id, sizeof(job.id), job_id); + safe_copy(job.state, sizeof(job.state), "queued"); + safe_copy(job.source_kind, sizeof(job.source_kind), "document"); + safe_copy(job.job_name, sizeof(job.job_name), + (job_name_override && *job_name_override) ? job_name_override + : (source_name && *source_name ? source_name : "document")); + safe_copy(job.user_name, sizeof(job.user_name), "montauk"); + safe_copy(job.printer_uri, sizeof(job.printer_uri), uri.normalized); + safe_copy(job.doc_path, sizeof(job.doc_path), doc_path); + safe_copy(job.doc_format, sizeof(job.doc_format), + (doc_format && *doc_format) ? doc_format : "application/octet-stream"); + safe_copy(job.source_name, sizeof(job.source_name), + (source_name && *source_name) ? source_name : "document"); + now_string(job.created_at, sizeof(job.created_at)); + safe_copy(job.updated_at, sizeof(job.updated_at), job.created_at); + safe_copy(job.status_message, sizeof(job.status_message), "Queued"); + snprintf(job.debug_info, sizeof(job.debug_info), + "printer=%s format=%s source=%s", + uri.normalized, job.doc_format, job.source_name); + job.remote_job_id = 0; + job.size_bytes = data_len; + job.copies = copies > 0 ? copies : 1; char job_path[MAX_PATH_LEN]; make_job_file_path(SPOOL_QUEUE_DIR, job.id, job_path, sizeof(job_path)); @@ -1860,7 +2100,8 @@ inline bool submit_document_job(const char* source_path, inline bool submit_test_page_job(const char* printer_uri, char* out_job_id, int out_job_id_len, - char* err, int err_len) { + char* err, int err_len, + int copies = 1) { ensure_spool_dirs(); char resolved_uri[MAX_PATH_LEN]; @@ -1889,6 +2130,7 @@ inline bool submit_test_page_job(const char* printer_uri, snprintf(job.debug_info, sizeof(job.debug_info), "printer=%s format=auto source=%s", uri.normalized, job.source_name); + job.copies = copies > 0 ? copies : 1; char job_path[MAX_PATH_LEN]; make_job_file_path(SPOOL_QUEUE_DIR, job.id, job_path, sizeof(job_path)); diff --git a/programs/src/dialogs/main.cpp b/programs/src/dialogs/main.cpp index 72ce9be..c2d520e 100644 --- a/programs/src/dialogs/main.cpp +++ b/programs/src/dialogs/main.cpp @@ -28,8 +28,8 @@ namespace { constexpr int FILE_INIT_W = 720; constexpr int FILE_INIT_H = 520; -constexpr int PRINT_INIT_W = 480; -constexpr int PRINT_INIT_H = 260; +constexpr int PRINT_INIT_W = 640; +constexpr int PRINT_INIT_H = 400; constexpr int TOOLBAR_H = 32; constexpr int PATHBAR_H = 32; @@ -46,6 +46,7 @@ constexpr int GRID_PAD = 4; constexpr int MAX_ENTRIES = 64; constexpr int MAX_HISTORY = 16; constexpr int MAX_DRIVES = 16; +constexpr int MAX_DISCOVERED_PRINTERS = 8; constexpr int BUTTON_H = 30; constexpr int BUTTON_W = 88; @@ -137,10 +138,24 @@ struct PrintDialogState { SvgIcon printer_icon; char source_path[256]; char job_name[128]; - char printer_uri[256]; - char printer_name[128]; - char status[160]; - bool can_print; + struct PrinterRow { + char uri[256]; + char name[128]; + char status[128]; + char seen_at[32]; + bool ok; + bool is_default; + } printers[MAX_DISCOVERED_PRINTERS]; + int printer_count; + int selected_printer; + uint32_t copies; + int queue_count; + int active_count; + bool daemon_running; + bool submit_mode; + bool source_ready; + char note[160]; + char error[160]; int mouse_x; int mouse_y; }; @@ -173,7 +188,12 @@ struct FileDialogLayout { }; struct PrintDialogLayout { - Rect content_rect; + Rect header_rect; + Rect printers_rect; + Rect detail_rect; + Rect copy_minus_btn; + Rect copy_value_rect; + Rect copy_plus_btn; Rect cancel_btn; Rect printers_btn; Rect refresh_btn; @@ -377,7 +397,13 @@ FileDialogLayout file_layout(const FileDialogState* st) { PrintDialogLayout print_layout() { PrintDialogLayout lo = {}; - lo.content_rect = {0, 0, g_app.win.width, g_app.win.height}; + lo.header_rect = {0, 0, 0, 0}; + lo.printers_rect = {16, 16, 212, g_app.win.height - 94}; + lo.detail_rect = {244, 16, g_app.win.width - 260, g_app.win.height - 94}; + int copy_y = lo.detail_rect.y + 202; + lo.copy_minus_btn = {lo.detail_rect.x + 16, copy_y, 28, 28}; + lo.copy_value_rect = {lo.copy_minus_btn.x + 36, copy_y, 104, 28}; + lo.copy_plus_btn = {lo.copy_value_rect.x + lo.copy_value_rect.w + 8, copy_y, 28, 28}; int btn_y = g_app.win.height - BUTTON_H - 16; lo.confirm_btn = {g_app.win.width - BUTTON_W - 16, btn_y, BUTTON_W, BUTTON_H}; lo.cancel_btn = {lo.confirm_btn.x - BUTTON_W - 8, btn_y, BUTTON_W, BUTTON_H}; @@ -386,10 +412,14 @@ PrintDialogLayout print_layout() { return lo; } -void dialog_finish(const char* status, const char* path, const char* job_id, const char* message) { +void dialog_finish(const char* status, const char* path, const char* job_id, const char* message, + const char* printer_uri = "", const char* printer_name = "", uint32_t copies = 0) { safe_copy(g_app.result.status, sizeof(g_app.result.status), status); safe_copy(g_app.result.path, sizeof(g_app.result.path), path); safe_copy(g_app.result.job_id, sizeof(g_app.result.job_id), job_id); + safe_copy(g_app.result.printer_uri, sizeof(g_app.result.printer_uri), printer_uri); + safe_copy(g_app.result.printer_name, sizeof(g_app.result.printer_name), printer_name); + g_app.result.copies = copies; safe_copy(g_app.result.message, sizeof(g_app.result.message), message); dlg::write_result_file(g_app.request.result_path, &g_app.result); g_app.running = false; @@ -1080,35 +1110,149 @@ void draw_file_dialog() { draw_action_button(c, lo.confirm_btn, confirm_label, confirm_enabled, lo.confirm_btn.contains(st->mouse_x, st->mouse_y), true); } -void print_refresh(PrintDialogState* st) { - st->printer_uri[0] = '\0'; - st->printer_name[0] = '\0'; - st->status[0] = '\0'; - st->can_print = false; +int count_dir_entries(const char* path) { + DIR* d = opendir(path); + if (!d) return 0; - if (!print::read_default_printer_uri(st->printer_uri, sizeof(st->printer_uri))) { - safe_copy(st->status, sizeof(st->status), "No default printer configured"); + int count = 0; + struct dirent* ent = nullptr; + while ((ent = readdir(d)) != nullptr) { + if (ent->d_name[0] == '.') continue; + count++; + } + closedir(d); + return count; +} + +void print_set_error(PrintDialogState* st, const char* msg) { + safe_copy(st->error, sizeof(st->error), msg); +} + +void print_adjust_copies(PrintDialogState* st, int delta) { + if (!st) return; + int next = (int)st->copies + delta; + if (next < 1) next = 1; + if (next > 99) next = 99; + st->copies = (uint32_t)next; +} + +void print_update_note(PrintDialogState* st) { + if (st->printer_count <= 0) { + safe_copy(st->note, sizeof(st->note), "No printers discovered from the userspace print spooler yet."); return; } - - print::ProbeState probe = {}; - if (print::load_printer_probe_state(&probe) && montauk::streq(probe.printer_uri, st->printer_uri)) { - if (probe.caps.printer_name[0]) safe_copy(st->printer_name, sizeof(st->printer_name), probe.caps.printer_name); - if (probe.message[0]) safe_copy(st->status, sizeof(st->status), probe.message); + if (!st->daemon_running) { + safe_copy(st->note, sizeof(st->note), "The print spooler is offline. Saved printers are still available."); + return; } - - if (!st->printer_name[0]) safe_copy(st->printer_name, sizeof(st->printer_name), "Default printer"); - if (!st->status[0]) safe_copy(st->status, sizeof(st->status), "Uses the system print spooler"); - - int fd = montauk::open(st->source_path); - if (fd >= 0) { - montauk::close(fd); - st->can_print = true; + if (st->submit_mode) { + safe_copy(st->note, sizeof(st->note), "The selected document will be handed to the userspace print spooler."); } else { - safe_copy(st->status, sizeof(st->status), "Source document is no longer available"); + safe_copy(st->note, sizeof(st->note), "This app stores printer settings only for now. Document output wiring will be added later."); } } +int print_find_printer(const PrintDialogState* st, const char* uri) { + if (!st || !uri || !uri[0]) return -1; + for (int i = 0; i < st->printer_count; i++) { + if (montauk::streq(st->printers[i].uri, uri)) return i; + } + return -1; +} + +void print_add_printer(PrintDialogState* st, + const char* uri, + const char* name, + const char* status, + const char* seen_at, + bool ok, + bool is_default) { + if (!st || !uri || !uri[0]) return; + + int idx = print_find_printer(st, uri); + if (idx < 0) { + if (st->printer_count >= MAX_DISCOVERED_PRINTERS) return; + idx = st->printer_count++; + montauk::memset(&st->printers[idx], 0, sizeof(st->printers[idx])); + safe_copy(st->printers[idx].uri, sizeof(st->printers[idx].uri), uri); + } + + if (name && name[0]) safe_copy(st->printers[idx].name, sizeof(st->printers[idx].name), name); + if (status && status[0]) safe_copy(st->printers[idx].status, sizeof(st->printers[idx].status), status); + if (seen_at && seen_at[0]) safe_copy(st->printers[idx].seen_at, sizeof(st->printers[idx].seen_at), seen_at); + st->printers[idx].ok = ok; + st->printers[idx].is_default = is_default; +} + +Rect print_row_rect(const PrintDialogLayout& lo, int idx) { + return {lo.printers_rect.x + 8, lo.printers_rect.y + 28 + idx * 58, lo.printers_rect.w - 16, 52}; +} + +bool print_can_confirm(const PrintDialogState* st) { + if (!st || st->selected_printer < 0 || st->selected_printer >= st->printer_count) return false; + if (!st->submit_mode) return true; + return st->source_ready; +} + +void print_refresh(PrintDialogState* st) { + if (!st) return; + + char preferred_uri[256] = {}; + if (st->selected_printer >= 0 && st->selected_printer < st->printer_count) + safe_copy(preferred_uri, sizeof(preferred_uri), st->printers[st->selected_printer].uri); + else if (g_app.request.printer_uri[0]) + safe_copy(preferred_uri, sizeof(preferred_uri), g_app.request.printer_uri); + + st->printer_count = 0; + st->selected_printer = -1; + st->error[0] = '\0'; + st->queue_count = count_dir_entries(print::SPOOL_QUEUE_DIR); + st->active_count = count_dir_entries(print::SPOOL_ACTIVE_DIR); + + int pid = -1; + st->daemon_running = print::daemon_is_running(&pid); + + print::KnownPrinter known[MAX_DISCOVERED_PRINTERS]; + int known_count = print::list_known_printers(known, MAX_DISCOVERED_PRINTERS); + for (int i = 0; i < known_count; i++) { + const char* status = known[i].status_message[0] + ? known[i].status_message + : (known[i].ok ? "Ready for queued jobs" : "Saved printer"); + print_add_printer(st, known[i].uri, known[i].display_name, status, known[i].last_seen, known[i].ok, known[i].is_default); + } + + if (st->printer_count <= 0) { + char default_uri[256] = {}; + bool have_default = print::read_default_printer_uri(default_uri, sizeof(default_uri)); + print::ProbeState probe = {}; + bool have_probe = print::load_printer_probe_state(&probe); + + if (have_probe && probe.printer_uri[0]) { + const char* status = probe.message[0] ? probe.message + : (probe.caps.status_message[0] ? probe.caps.status_message + : (probe.ok ? "Ready for queued jobs" : "Saved printer")); + print_add_printer(st, probe.printer_uri, probe.caps.printer_name, status, probe.probed_at, + probe.ok, have_default && montauk::streq(default_uri, probe.printer_uri)); + } else if (have_default) { + print_add_printer(st, default_uri, "Configured printer", "Saved by the print spooler", "", false, true); + } + } + + if (preferred_uri[0]) st->selected_printer = print_find_printer(st, preferred_uri); + if (st->selected_printer < 0) { + for (int i = 0; i < st->printer_count; i++) { + if (st->printers[i].is_default) { + st->selected_printer = i; + break; + } + } + } + if (st->selected_printer < 0 && st->printer_count > 0) st->selected_printer = 0; + + st->source_ready = !st->submit_mode || (st->source_path[0] && path_exists_via_open(st->source_path)); + print_update_note(st); +} + void init_print_dialog(const dlg::Request& req) { PrintDialogState* st = &g_app.print; montauk::memset(st, 0, sizeof(*st)); @@ -1116,6 +1260,8 @@ void init_print_dialog(const dlg::Request& req) { st->printer_icon = svg_load("0:/icons/printer-symbolic.svg", 24, 24, def); safe_copy(st->source_path, sizeof(st->source_path), req.source_path); safe_copy(st->job_name, sizeof(st->job_name), req.job_name); + st->submit_mode = req.mode == dlg::PRINT_DIALOG_SUBMIT; + st->copies = req.copies > 0 ? req.copies : 1; print_refresh(st); } @@ -1124,44 +1270,132 @@ void draw_print_dialog() { Canvas c = g_app.win.canvas(); c.fill(colors::WINDOW_BG); PrintDialogLayout lo = print_layout(); + const auto* selected = (st->selected_printer >= 0 && st->selected_printer < st->printer_count) + ? &st->printers[st->selected_printer] + : nullptr; - c.fill_rect(0, 0, c.w, 54, Color::from_rgb(0xF7, 0xF7, 0xF7)); - c.hline(0, 53, c.w, colors::BORDER); - if (st->printer_icon.pixels) c.icon(18, 15, st->printer_icon); - c.text(54, 16, st->printer_name[0] ? st->printer_name : "Print", colors::TEXT_COLOR); + c.fill_rounded_rect(lo.printers_rect.x, lo.printers_rect.y, lo.printers_rect.w, lo.printers_rect.h, 6, colors::WHITE); + c.rect(lo.printers_rect.x, lo.printers_rect.y, lo.printers_rect.w, lo.printers_rect.h, colors::BORDER); + c.text(lo.printers_rect.x + 12, lo.printers_rect.y + 8, "Printers", Color::from_rgb(0x77, 0x77, 0x77)); - char source_line[256]; - fit_text_end(path_basename(st->source_path), source_line, sizeof(source_line), c.w - 36); - c.text(18, 78, "Document", Color::from_rgb(0x77, 0x77, 0x77)); - c.text(18, 100, source_line, colors::TEXT_COLOR); + if (st->printer_count <= 0) { + c.text(lo.printers_rect.x + 12, lo.printers_rect.y + 38, "No printers discovered", colors::TEXT_COLOR); + c.text(lo.printers_rect.x + 12, lo.printers_rect.y + 58, "Open Printers to configure one.", Color::from_rgb(0x77, 0x77, 0x77)); + } else { + for (int i = 0; i < st->printer_count; i++) { + Rect row = print_row_rect(lo, i); + bool selected = i == st->selected_printer; + bool hovered = row.contains(st->mouse_x, st->mouse_y); + Color bg = selected ? colors::MENU_HOVER + : (hovered ? Color::from_rgb(0xF6, 0xF6, 0xF6) : colors::WHITE); + c.fill_rounded_rect(row.x, row.y, row.w, row.h, 6, bg); + c.rect(row.x, row.y, row.w, row.h, selected ? colors::ACCENT : Color::from_rgb(0xE4, 0xE4, 0xE4)); + if (st->printer_icon.pixels) c.icon(row.x + 10, row.y + 14, st->printer_icon); - c.text(18, 136, "Printer", Color::from_rgb(0x77, 0x77, 0x77)); - char printer_line[256]; - fit_text_end(st->printer_uri[0] ? st->printer_uri : "Not configured", printer_line, sizeof(printer_line), c.w - 36); - c.text(18, 158, printer_line, colors::TEXT_COLOR); + char display[128]; + if (st->printers[i].is_default && st->printers[i].name[0]) + snprintf(display, sizeof(display), "%s (Default)", st->printers[i].name); + else if (st->printers[i].is_default) + snprintf(display, sizeof(display), "Default printer"); + else + safe_copy(display, sizeof(display), st->printers[i].name[0] ? st->printers[i].name : st->printers[i].uri); - char status_line[160]; - fit_text_end(st->status, status_line, sizeof(status_line), c.w - 36); - c.text(18, 190, status_line, st->can_print ? Color::from_rgb(0x66, 0x66, 0x66) : Color::from_rgb(0xC0, 0x33, 0x33)); + char title[112]; + fit_text_end(display, title, sizeof(title), row.w - 68); + c.text(row.x + 42, row.y + 9, title, colors::TEXT_COLOR); + + char subtitle[256]; + safe_copy(subtitle, sizeof(subtitle), st->printers[i].uri[0] ? st->printers[i].uri : "Saved printer"); + fit_text_end(subtitle, title, sizeof(title), row.w - 68); + c.text(row.x + 42, row.y + 28, title, Color::from_rgb(0x77, 0x77, 0x77)); + } + } + + c.fill_rounded_rect(lo.detail_rect.x, lo.detail_rect.y, lo.detail_rect.w, lo.detail_rect.h, 6, colors::WHITE); + c.rect(lo.detail_rect.x, lo.detail_rect.y, lo.detail_rect.w, lo.detail_rect.h, colors::BORDER); + + int dx = lo.detail_rect.x + 16; + int dy = lo.detail_rect.y + 18; + c.text(dx, dy, "Document", Color::from_rgb(0x77, 0x77, 0x77)); + char line[256]; + const char* job_label = st->job_name[0] ? st->job_name + : (st->source_path[0] ? path_basename(st->source_path) : "Untitled document"); + fit_text_end(job_label, line, sizeof(line), lo.detail_rect.w - 32); + c.text(dx, dy + 20, line, colors::TEXT_COLOR); + + dy += 68; + c.text(dx, dy, "Printer", Color::from_rgb(0x77, 0x77, 0x77)); + if (selected) { + const char* title = selected->name[0] ? selected->name : selected->uri; + fit_text_end(title, line, sizeof(line), lo.detail_rect.w - 32); + c.text(dx, dy + 18, line, colors::TEXT_COLOR); + + const char* subtitle = selected->uri[0] ? selected->uri : selected->status; + fit_text_end(subtitle, line, sizeof(line), lo.detail_rect.w - 32); + c.text(dx, dy + 36, line, Color::from_rgb(0x77, 0x77, 0x77)); + } else { + c.text(dx, dy + 18, "No printer selected", Color::from_rgb(0x77, 0x77, 0x77)); + } + + dy += 86; + c.text(dx, dy, "Copies", Color::from_rgb(0x77, 0x77, 0x77)); + draw_action_button(c, lo.copy_minus_btn, "-", st->copies > 1, + lo.copy_minus_btn.contains(st->mouse_x, st->mouse_y), false); + c.fill_rounded_rect(lo.copy_value_rect.x, lo.copy_value_rect.y, lo.copy_value_rect.w, lo.copy_value_rect.h, 4, Color::from_rgb(0xF6, 0xF6, 0xF6)); + c.rect(lo.copy_value_rect.x, lo.copy_value_rect.y, lo.copy_value_rect.w, lo.copy_value_rect.h, colors::BORDER); + snprintf(line, sizeof(line), "%u %s", (unsigned)st->copies, st->copies == 1 ? "copy" : "copies"); + c.text(lo.copy_value_rect.x + 10, lo.copy_value_rect.y + 7, line, colors::TEXT_COLOR); + draw_action_button(c, lo.copy_plus_btn, "+", st->copies < 99, + lo.copy_plus_btn.contains(st->mouse_x, st->mouse_y), false); + + if (st->error[0]) { + char line[160]; + fit_text_end(st->error, line, sizeof(line), lo.cancel_btn.x - 24); + c.text(16, lo.cancel_btn.y - 22, line, Color::from_rgb(0xC0, 0x33, 0x33)); + } draw_action_button(c, lo.refresh_btn, "Refresh", true, lo.refresh_btn.contains(st->mouse_x, st->mouse_y), false); draw_action_button(c, lo.printers_btn, "Printers", true, lo.printers_btn.contains(st->mouse_x, st->mouse_y), false); draw_action_button(c, lo.cancel_btn, "Cancel", true, lo.cancel_btn.contains(st->mouse_x, st->mouse_y), false); - draw_action_button(c, lo.confirm_btn, "Print", st->can_print && st->printer_uri[0], lo.confirm_btn.contains(st->mouse_x, st->mouse_y), true); + draw_action_button(c, lo.confirm_btn, "Print", print_can_confirm(st), + lo.confirm_btn.contains(st->mouse_x, st->mouse_y), true); +} + +void print_finish_setup(PrintDialogState* st) { + if (!st || st->selected_printer < 0 || st->selected_printer >= st->printer_count) { + print_set_error(st, "Choose a printer to continue"); + return; + } + + const auto& printer = st->printers[st->selected_printer]; + dialog_finish("ok", "", "", "Printer selected", + printer.uri, printer.name, st->copies); } void print_submit(PrintDialogState* st) { + if (!st || st->selected_printer < 0 || st->selected_printer >= st->printer_count) { + print_set_error(st, "Choose a printer to print"); + return; + } + if (!st->source_ready) { + print_set_error(st, "Source document is unavailable"); + return; + } + + const auto& printer = st->printers[st->selected_printer]; char job_id[64] = {}; char err[160] = {}; const char* job_name = st->job_name[0] ? st->job_name : path_basename(st->source_path); - if (!print::submit_document_job(st->source_path, nullptr, job_name, job_id, sizeof(job_id), err, sizeof(err))) { - safe_copy(st->status, sizeof(st->status), err[0] ? err : "Failed to queue print job"); + if (!print::submit_document_job(st->source_path, printer.uri, job_name, + job_id, sizeof(job_id), err, sizeof(err), + (int)(st->copies > 0 ? st->copies : 1))) { + print_set_error(st, err[0] ? err : "Failed to queue print job"); return; } char msg[160]; snprintf(msg, sizeof(msg), "Queued as %s", job_id); - dialog_finish("ok", "", job_id, msg); + dialog_finish("ok", "", job_id, msg, printer.uri, printer.name, st->copies); } void text_insert(char* buf, int* len, int* cursor, int max_len, char ch) { @@ -1375,8 +1609,27 @@ void handle_print_mouse(const Montauk::WinEvent& ev) { if (lo.cancel_btn.contains(me.x, me.y)) { dialog_cancel(); return; } if (lo.refresh_btn.contains(me.x, me.y)) { print_refresh(st); return; } if (lo.printers_btn.contains(me.x, me.y)) { montauk::spawn("0:/apps/printers/printers.elf"); return; } - if (lo.confirm_btn.contains(me.x, me.y) && st->can_print && st->printer_uri[0]) { - print_submit(st); + if (lo.copy_minus_btn.contains(me.x, me.y) && st->copies > 1) { + print_adjust_copies(st, -1); + st->error[0] = '\0'; + return; + } + if (lo.copy_plus_btn.contains(me.x, me.y) && st->copies < 99) { + print_adjust_copies(st, +1); + st->error[0] = '\0'; + return; + } + for (int i = 0; i < st->printer_count; i++) { + Rect row = print_row_rect(lo, i); + if (row.contains(me.x, me.y)) { + st->selected_printer = i; + st->error[0] = '\0'; + return; + } + } + if (lo.confirm_btn.contains(me.x, me.y) && print_can_confirm(st)) { + if (st->submit_mode) print_submit(st); + else print_finish_setup(st); } } @@ -1386,11 +1639,34 @@ void handle_print_key(const Montauk::KeyEvent& key) { dialog_cancel(); return; } - if ((key.ascii == '\n' || key.ascii == '\r') && g_app.print.can_print && g_app.print.printer_uri[0]) { - print_submit(&g_app.print); + if ((key.ascii == '\n' || key.ascii == '\r') && print_can_confirm(&g_app.print)) { + if (g_app.print.submit_mode) print_submit(&g_app.print); + else print_finish_setup(&g_app.print); } if (key.ctrl && (key.ascii == 'r' || key.ascii == 'R')) { print_refresh(&g_app.print); + return; + } + if ((key.ascii == '+' || key.ascii == '=') && g_app.print.copies < 99) { + print_adjust_copies(&g_app.print, +1); + g_app.print.error[0] = '\0'; + return; + } + if ((key.ascii == '-' || key.ascii == '_') && g_app.print.copies > 1) { + print_adjust_copies(&g_app.print, -1); + g_app.print.error[0] = '\0'; + return; + } + if ((key.scancode == 0x48 || key.scancode == 0x4B) && g_app.print.printer_count > 0) { + if (g_app.print.selected_printer > 0) g_app.print.selected_printer--; + else g_app.print.selected_printer = 0; + g_app.print.error[0] = '\0'; + return; + } + if ((key.scancode == 0x50 || key.scancode == 0x4D) && g_app.print.printer_count > 0) { + if (g_app.print.selected_printer < g_app.print.printer_count - 1) g_app.print.selected_printer++; + else g_app.print.selected_printer = g_app.print.printer_count - 1; + g_app.print.error[0] = '\0'; } } diff --git a/programs/src/printctl/main.cpp b/programs/src/printctl/main.cpp index bce6d5b..a239937 100644 --- a/programs/src/printctl/main.cpp +++ b/programs/src/printctl/main.cpp @@ -70,6 +70,7 @@ static void print_job_detail(const JobMeta& job, const char* state, bool verbose if (job.printer_uri[0]) printf(" printer: %s\n", job.printer_uri); if (job.doc_format[0]) printf(" format: %s\n", job.doc_format); + if (job.copies > 1) printf(" copies: %d\n", job.copies); if (job.source_name[0]) printf(" source: %s\n", job.source_name); if (job.remote_job_id > 0) printf(" printer job id: %d\n", job.remote_job_id); if (job.status_message[0]) printf(" status: %s\n", job.status_message); diff --git a/programs/src/printd/main.cpp b/programs/src/printd/main.cpp index 5ab640e..05f7bc4 100644 --- a/programs/src/printd/main.cpp +++ b/programs/src/printd/main.cpp @@ -15,8 +15,30 @@ extern "C" { using namespace print; -static constexpr uint64_t PROBE_REFRESH_OK_MS = 60000; -static constexpr uint64_t PROBE_REFRESH_FAIL_MS = 15000; +static constexpr bool ENABLE_BACKGROUND_PROBE = false; + +enum class ClaimResult { + None, + Claimed, + Failed, +}; + +static void write_daemon_state(const char* phase, const char* subject = nullptr, + const char* detail = nullptr) { + char now[32] = {}; + now_string(now, sizeof(now)); + + char buf[512]; + int n = snprintf(buf, sizeof(buf), + "time=%s\npid=%d\nphase=%s\nsubject=%s\ndetail=%s\n", + now, + montauk::getpid(), + phase ? phase : "", + subject ? subject : "", + detail ? detail : ""); + if (n < 0) return; + write_text_file_atomic(SPOOL_DAEMON_STATE_PATH, buf); +} static void log_msg(const char* fmt, ...) { va_list ap; @@ -68,6 +90,41 @@ static bool write_pid_file() { return write_text_file_atomic(SPOOL_DAEMON_PID_PATH, pid); } +static bool move_job_file(const char* src, const char* dst, char* err = nullptr, int err_len = 0) { + if (err && err_len > 0) err[0] = '\0'; + if (src == nullptr || dst == nullptr || *src == '\0' || *dst == '\0') { + safe_copy(err, err_len, "invalid source or destination path"); + return false; + } + if (rename(src, dst) == 0) return true; + + uint8_t* data = nullptr; + int len = 0; + char io_err[128] = {}; + if (!read_file_bytes(src, &data, &len, io_err, sizeof(io_err))) { + char detail[160]; + snprintf(detail, sizeof(detail), "rename failed; could not read source (%s)", io_err); + safe_copy(err, err_len, detail); + return false; + } + + bool ok = write_file_atomic(dst, data, len, io_err, sizeof(io_err)); + free(data); + if (!ok) { + char detail[160]; + snprintf(detail, sizeof(detail), "rename failed; could not write destination (%s)", io_err); + safe_copy(err, err_len, detail); + return false; + } + + if (remove(src) != 0) { + remove(dst); + safe_copy(err, err_len, "rename failed; copied destination but could not delete source"); + return false; + } + return true; +} + static void auto_probe_configured_printer(char* last_uri, int last_uri_len, uint64_t* last_probe_ms, bool* last_probe_ok, bool force = false) { @@ -90,8 +147,7 @@ static void auto_probe_configured_printer(char* last_uri, int last_uri_len, } bool uri_changed = strcmp(last_uri, current_uri) != 0; - uint64_t refresh_ms = *last_probe_ok ? PROBE_REFRESH_OK_MS : PROBE_REFRESH_FAIL_MS; - if (!force && !uri_changed && *last_probe_ms != 0 && now - *last_probe_ms < refresh_ms) + if (!force && !uri_changed && *last_probe_ms != 0) return; ProbeState state = {}; @@ -136,42 +192,59 @@ static void requeue_stale_active_jobs() { set_job_state(&job, "queued", "Re-queued after daemon restart"); save_job_to_path_atomic(src, &job); } - rename(src, dst); + char move_err[160] = {}; + if (!move_job_file(src, dst, move_err, sizeof(move_err))) { + log_msg("warning: failed to requeue stale job %s: %s", + ent->d_name, move_err[0] ? move_err : "move failed"); + } } closedir(dir); } -static bool claim_next_job(char* out_active_path, int out_active_path_len) { +static ClaimResult claim_next_job(char* out_active_path, int out_active_path_len) { DIR* dir = opendir(SPOOL_QUEUE_DIR); - if (!dir) return false; + if (!dir) return ClaimResult::None; - bool claimed = false; + char best_name[64] = {}; struct dirent* ent = nullptr; while ((ent = readdir(dir)) != nullptr) { if (ent->d_name[0] == '.') continue; - - char queued[MAX_PATH_LEN]; - char active[MAX_PATH_LEN]; - path_join(queued, sizeof(queued), SPOOL_QUEUE_DIR, ent->d_name); - path_join(active, sizeof(active), SPOOL_ACTIVE_DIR, ent->d_name); - - if (rename(queued, active) == 0) { - safe_copy(out_active_path, out_active_path_len, active); - claimed = true; - break; - } + if (best_name[0] == '\0' || strcmp(ent->d_name, best_name) < 0) + safe_copy(best_name, sizeof(best_name), ent->d_name); } - closedir(dir); - return claimed; + if (best_name[0] == '\0') return ClaimResult::None; + + char queued[MAX_PATH_LEN]; + char active[MAX_PATH_LEN]; + path_join(queued, sizeof(queued), SPOOL_QUEUE_DIR, best_name); + path_join(active, sizeof(active), SPOOL_ACTIVE_DIR, best_name); + + write_daemon_state("claiming", best_name, queued); + char move_err[160] = {}; + if (!move_job_file(queued, active, move_err, sizeof(move_err))) { + char detail[256]; + snprintf(detail, sizeof(detail), "%s -> %s (%s)", + queued, active, + move_err[0] ? move_err : "move failed"); + write_daemon_state("claim-failed", best_name, detail); + log_msg("failed to claim job %s: %s", best_name, detail); + return ClaimResult::Failed; + } + write_daemon_state("claimed", best_name, active); + safe_copy(out_active_path, out_active_path_len, active); + return ClaimResult::Claimed; } static void finalize_job(const char* active_path, const char* target_dir, JobMeta* job) { save_job_to_path_atomic(active_path, job); char target[MAX_PATH_LEN]; make_job_file_path(target_dir, job->id, target, sizeof(target)); - if (rename(active_path, target) != 0) { + char move_err[160] = {}; + if (!move_job_file(active_path, target, move_err, sizeof(move_err))) { log_msg("warning: failed to move job %s to %s", job->id, target_dir); + write_daemon_state("finalize-failed", job->id, + move_err[0] ? move_err : target_dir); } } @@ -223,12 +296,14 @@ static bool try_print_test_page(const char* active_path, JobMeta* job, } char detail[MAX_DEBUG_LEN]; - snprintf(detail, sizeof(detail), "submitting test page as %s (%d bytes)", - attempts[i].mime, len); + snprintf(detail, sizeof(detail), "submitting test page as %s (%d bytes, %d %s)", + attempts[i].mime, len, + job->copies > 0 ? job->copies : 1, + (job->copies > 1) ? "copies" : "copy"); persist_job_debug(active_path, job, detail); bool ok = ipp_print_buffer(job->printer_uri, job->job_name, job->user_name, - attempts[i].mime, data, len, result, err, err_len); + job->copies, attempts[i].mime, data, len, result, err, err_len); free(data); if (ok) return true; } @@ -245,12 +320,14 @@ static bool try_print_document(const char* active_path, JobMeta* job, if (!read_file_bytes(job->doc_path, &data, &len, err, err_len)) return false; char detail[MAX_DEBUG_LEN]; - snprintf(detail, sizeof(detail), "submitting %s document (%d bytes)", - job->doc_format[0] ? job->doc_format : "application/octet-stream", len); + snprintf(detail, sizeof(detail), "submitting %s document (%d bytes, %d %s)", + job->doc_format[0] ? job->doc_format : "application/octet-stream", len, + job->copies > 0 ? job->copies : 1, + (job->copies > 1) ? "copies" : "copy"); persist_job_debug(active_path, job, detail); bool ok = ipp_print_buffer(job->printer_uri, job->job_name, job->user_name, - job->doc_format, data, len, result, err, err_len); + job->copies, job->doc_format, data, len, result, err, err_len); free(data); return ok; } @@ -259,11 +336,13 @@ static void process_job(const char* active_path) { JobMeta job = {}; if (!load_job_from_path(active_path, &job)) { log_msg("dropping unreadable job file: %s", active_path); + write_daemon_state("unreadable-job", active_path, nullptr); remove(active_path); return; } log_msg("processing job %s (%s)", job.id, job.job_name); + write_daemon_state("processing", job.id, job.job_name); set_job_state(&job, "printing", "Sending to printer"); set_job_debug(&job, "dispatching queued job"); save_job_to_path_atomic(active_path, &job); @@ -284,12 +363,14 @@ static void process_job(const char* active_path) { set_job_debug_from_result(&job, &result, nullptr); finalize_job(active_path, SPOOL_DONE_DIR, &job); log_msg("job %s completed (printer job id %d)", job.id, result.job_id); + write_daemon_state("completed", job.id, job.status_message); } else { set_job_state(&job, "failed", err[0] ? err : "Print failed"); set_job_debug_from_result(&job, &result, err[0] ? err : nullptr); finalize_job(active_path, SPOOL_FAILED_DIR, &job); log_msg("job %s failed: %s", job.id, job.status_message); if (job.debug_info[0]) log_msg("job %s debug: %s", job.id, job.debug_info); + write_daemon_state("failed", job.id, job.status_message); } if (job.doc_path[0] != '\0') remove(job.doc_path); @@ -316,18 +397,25 @@ extern "C" void _start() { char last_probed_uri[MAX_PATH_LEN] = {}; uint64_t last_probe_ms = 0; bool last_probe_ok = false; - auto_probe_configured_printer(last_probed_uri, sizeof(last_probed_uri), - &last_probe_ms, &last_probe_ok, true); log_msg("ready"); + write_daemon_state("ready", nullptr, nullptr); for (;;) { - auto_probe_configured_printer(last_probed_uri, sizeof(last_probed_uri), - &last_probe_ms, &last_probe_ok); char active_path[MAX_PATH_LEN]; - if (claim_next_job(active_path, sizeof(active_path))) { + ClaimResult claim = claim_next_job(active_path, sizeof(active_path)); + if (claim == ClaimResult::Claimed) { process_job(active_path); continue; } + if (claim == ClaimResult::Failed) { + montauk::sleep_ms(1000); + continue; + } + write_daemon_state("idle", nullptr, nullptr); + if (ENABLE_BACKGROUND_PROBE) { + auto_probe_configured_printer(last_probed_uri, sizeof(last_probed_uri), + &last_probe_ms, &last_probe_ok); + } montauk::sleep_ms(1000); } } diff --git a/programs/src/wordprocessor/Makefile b/programs/src/wordprocessor/Makefile index f8278ac..e3eb416 100644 --- a/programs/src/wordprocessor/Makefile +++ b/programs/src/wordprocessor/Makefile @@ -41,6 +41,7 @@ CXXFLAGS := \ -mcmodel=small \ -I $(PROG_INC) \ -isystem $(PROG_INC)/libc \ + -I ../../lib/bearssl/inc \ -isystem ../../../kernel/freestnd-c-hdrs/x86_64/include \ -isystem ../../../kernel/freestnd-cxx-hdrs/x86_64/include @@ -53,8 +54,9 @@ LDFLAGS := \ -z max-page-size=0x1000 \ -T $(LINK_LD) -SRCS := main.cpp document.cpp render.cpp input.cpp stb_truetype_impl.cpp +SRCS := main.cpp document.cpp render.cpp input.cpp print_export.cpp stb_truetype_impl.cpp OBJS := $(addprefix $(OBJDIR)/,$(SRCS:.cpp=.o)) +LIBS := $(LIBDIR)/tls/libtls.a $(LIBDIR)/bearssl/libbearssl.a $(LIBDIR)/libjpegwrite/libjpegwrite.a $(LIBDIR)/libc/liblibc.a TARGET := $(BINDIR)/apps/wordprocessor/wordprocessor.elf @@ -62,9 +64,9 @@ TARGET := $(BINDIR)/apps/wordprocessor/wordprocessor.elf all: $(TARGET) -$(TARGET): $(OBJS) $(LINK_LD) Makefile +$(TARGET): $(OBJS) $(LINK_LD) Makefile $(LIBS) mkdir -p $(BINDIR)/apps/wordprocessor - $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBDIR)/libc/liblibc.a -o $@ + $(CXX) $(CXXFLAGS) $(LDFLAGS) $(OBJS) $(LIBS) -o $@ $(OBJDIR)/%.o: %.cpp Makefile mkdir -p $(OBJDIR) diff --git a/programs/src/wordprocessor/document.cpp b/programs/src/wordprocessor/document.cpp index 75f6468..ca6bd53 100644 --- a/programs/src/wordprocessor/document.cpp +++ b/programs/src/wordprocessor/document.cpp @@ -419,6 +419,10 @@ void wp_init_empty_document(WordProcessorState* wp) { wp->modified = false; wp->filepath[0] = '\0'; wp->filename[0] = '\0'; + wp->preferred_printer_uri[0] = '\0'; + wp->preferred_printer_name[0] = '\0'; + wp->preferred_print_copies = 1; + wp->status_msg[0] = '\0'; wp->show_pathbar = false; wp->pathbar_save_mode = false; @@ -429,6 +433,7 @@ void wp_init_empty_document(WordProcessorState* wp) { wp->font_dropdown_open = false; wp->size_dropdown_open = false; wp->line_spacing_dropdown_open = false; + wp->special_char_flyout_open = false; wp->undo_count = 0; wp->undo_pos = 0; diff --git a/programs/src/wordprocessor/input.cpp b/programs/src/wordprocessor/input.cpp index 22b2270..19c0178 100644 --- a/programs/src/wordprocessor/input.cpp +++ b/programs/src/wordprocessor/input.cpp @@ -7,6 +7,12 @@ #include "wordprocessor.hpp" #include +static void wp_set_status(WordProcessorState* wp, const char* msg) { + if (!msg) msg = ""; + montauk::strncpy(wp->status_msg, msg, (int)sizeof(wp->status_msg) - 1); + wp->status_msg[sizeof(wp->status_msg) - 1] = '\0'; +} + static void wp_open_pathbar_for_open(WordProcessorState* wp) { char path[256] = {}; char msg[160] = {}; @@ -17,6 +23,12 @@ static void wp_open_pathbar_for_open(WordProcessorState* wp) { } } +static void wp_open_print_dialog(WordProcessorState* wp) { + char msg[160] = {}; + if (wp_print_document(wp, msg, sizeof(msg)) || msg[0]) + wp_set_status(wp, msg); +} + static void wp_commit_pathbar(WordProcessorState* wp) { if (!wp->pathbar_text[0]) return; if (wp->pathbar_save_mode) { @@ -28,10 +40,23 @@ static void wp_commit_pathbar(WordProcessorState* wp) { wp->show_pathbar = false; } +static int wp_special_char_flyout_x() { + int dx = WP_BTN_SECTION_X + 24 - WP_SPECIAL_CHAR_FLYOUT_W; + if (dx + WP_SPECIAL_CHAR_FLYOUT_W > g_win_w) dx = g_win_w - WP_SPECIAL_CHAR_FLYOUT_W; + return dx < 0 ? 0 : dx; +} + static void wp_close_dropdowns(WordProcessorState* wp) { wp->font_dropdown_open = false; wp->size_dropdown_open = false; wp->line_spacing_dropdown_open = false; + wp->special_char_flyout_open = false; +} + +static void wp_insert_special_char(WordProcessorState* wp, uint8_t ch) { + if (wp->has_selection) wp_delete_selection(wp); + wp_insert_char(wp, (char)ch); + wp_history_checkpoint(wp); } static int wp_hit_test_text(WordProcessorState* wp, int local_x, int local_y, int edit_y) { @@ -148,6 +173,19 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { return; } + if (wp->special_char_flyout_open && wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons)) { + int dx = wp_special_char_flyout_x(); + int dy = WP_TOOLBAR_H; + int dh = WP_SPECIAL_CHAR_OPTION_COUNT * WP_SPECIAL_CHAR_ROW_H + 4; + if (local_x >= dx && local_x < dx + WP_SPECIAL_CHAR_FLYOUT_W && local_y >= dy && local_y < dy + dh) { + int idx = (local_y - dy - 2) / WP_SPECIAL_CHAR_ROW_H; + if (idx >= 0 && idx < WP_SPECIAL_CHAR_OPTION_COUNT) + wp_insert_special_char(wp, WP_SPECIAL_CHAR_OPTIONS[idx].code); + } + wp->special_char_flyout_open = false; + return; + } + if (wp_left_pressed(ev.mouse.buttons, ev.mouse.prev_buttons) && local_y < WP_TOOLBAR_H) { if (local_x >= WP_BTN_OPEN_X && local_x < WP_BTN_OPEN_X + 24 && local_y >= 6 && local_y < 30) { wp_open_pathbar_for_open(wp); @@ -157,6 +195,10 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { wp_save_file(wp); return; } + if (local_x >= WP_BTN_PRINT_X && local_x < WP_BTN_PRINT_X + 24 && local_y >= 6 && local_y < 30) { + wp_open_print_dialog(wp); + return; + } if (local_x >= WP_BTN_UNDO_X && local_x < WP_BTN_UNDO_X + 24 && local_y >= 6 && local_y < 30) { wp_close_dropdowns(wp); wp_undo(wp); @@ -237,12 +279,13 @@ void wp_handle_mouse(const Montauk::WinEvent& ev) { wp->line_spacing_dropdown_open = !wp->line_spacing_dropdown_open; wp->font_dropdown_open = false; wp->size_dropdown_open = false; + wp->special_char_flyout_open = false; return; } if (local_x >= WP_BTN_SECTION_X && local_x < WP_BTN_SECTION_X + 24 && local_y >= 6 && local_y < 30) { + bool open = !wp->special_char_flyout_open; wp_close_dropdowns(wp); - wp_insert_char(wp, (char)0xA7); - wp_history_checkpoint(wp); + wp->special_char_flyout_open = open; return; } return; @@ -349,6 +392,10 @@ void wp_handle_key(const Montauk::KeyEvent& key) { wp_open_pathbar_for_open(wp); return; } + if (key.ctrl && (key.ascii == 'p' || key.ascii == 'P')) { + wp_open_print_dialog(wp); + return; + } if (key.ctrl && !key.alt && (key.ascii == 'z' || key.ascii == 'Z')) { diff --git a/programs/src/wordprocessor/main.cpp b/programs/src/wordprocessor/main.cpp index 91ef7d2..b8250c6 100644 --- a/programs/src/wordprocessor/main.cpp +++ b/programs/src/wordprocessor/main.cpp @@ -13,6 +13,7 @@ WordProcessorState g_wp = {}; WPFontTable g_wp_fonts = { {{nullptr}}, false }; SvgIcon g_icon_folder = {}; SvgIcon g_icon_save = {}; +SvgIcon g_icon_print = {}; SvgIcon g_icon_undo = {}; SvgIcon g_icon_redo = {}; SvgIcon g_icon_align_left = {}; @@ -31,6 +32,8 @@ void wp_load_icons() { g_icon_folder = svg_load("0:/icons/folder.svg", 16, 16, def_color); if (!g_icon_save.pixels) g_icon_save = svg_load("0:/icons/document-save-symbolic.svg", 16, 16, def_color); + if (!g_icon_print.pixels) + g_icon_print = svg_load("0:/icons/printer-symbolic.svg", 16, 16, def_color); if (!g_icon_undo.pixels) g_icon_undo = svg_load("0:/icons/edit-undo-symbolic.svg", 16, 16, def_color); if (!g_icon_redo.pixels) @@ -55,6 +58,7 @@ void wp_cleanup_state() { wp_free_document(&g_wp); if (g_icon_folder.pixels) svg_free(g_icon_folder); if (g_icon_save.pixels) svg_free(g_icon_save); + if (g_icon_print.pixels) svg_free(g_icon_print); if (g_icon_undo.pixels) svg_free(g_icon_undo); if (g_icon_redo.pixels) svg_free(g_icon_redo); if (g_icon_align_left.pixels) svg_free(g_icon_align_left); diff --git a/programs/src/wordprocessor/print_export.cpp b/programs/src/wordprocessor/print_export.cpp new file mode 100644 index 0000000..6c4046b --- /dev/null +++ b/programs/src/wordprocessor/print_export.cpp @@ -0,0 +1,433 @@ +/* + * print_export.cpp + * Word Processor print export and job submission + * Copyright (c) 2026 Daniel Hammer + */ + +#include "wordprocessor.hpp" +#include +#include + +extern "C" { +#include +#include + +#define STBI_WRITE_NO_STDIO +#include +} + +namespace dialogs = gui::dialogs; + +static constexpr int WP_PRINT_PAGE_W = 1275; // 8.5in at 150 DPI +static constexpr int WP_PRINT_PAGE_H = 1650; // 11in at 150 DPI +static constexpr int WP_PRINT_MARGIN_X = 96; +static constexpr int WP_PRINT_MARGIN_Y = 96; +static constexpr int WP_PRINT_JPEG_QUALITY = 92; + +struct WpJpegBuffer { + uint8_t* data; + int size; + int capacity; + bool failed; +}; + +static void wp_status_copy(char* out, int out_len, const char* text) { + if (!out || out_len <= 0) return; + if (!text) text = ""; + montauk::strncpy(out, text, out_len - 1); + out[out_len - 1] = '\0'; +} + +static void wp_jpeg_write_callback(void* context, void* data, int size) { + WpJpegBuffer* buf = (WpJpegBuffer*)context; + if (!buf || !data || size <= 0 || buf->failed) return; + + int needed = buf->size + size; + if (needed > buf->capacity) { + int new_cap = buf->capacity > 0 ? buf->capacity : 256 * 1024; + while (new_cap < needed) + new_cap *= 2; + uint8_t* grown = (uint8_t*)montauk::realloc(buf->data, new_cap); + if (!grown) { + buf->failed = true; + return; + } + buf->data = grown; + buf->capacity = new_cap; + } + + montauk::memcpy(buf->data + buf->size, data, size); + buf->size += size; +} + +static int wp_print_layout_width() { + return (WP_PRINT_PAGE_W - WP_PRINT_MARGIN_X * 2) + WP_MARGIN * 2 + WP_SCROLLBAR_W; +} + +static void wp_make_base_print_name(WordProcessorState* wp, char* out, int out_len) { + const char* src = wp->filename[0] ? wp->filename : "document"; + int last_dot = -1; + for (int i = 0; src[i]; i++) { + if (src[i] == '.') last_dot = i; + } + + if (last_dot <= 0) { + wp_status_copy(out, out_len, src); + return; + } + + int copy_len = last_dot; + if (copy_len >= out_len) copy_len = out_len - 1; + montauk::memcpy(out, src, (uint64_t)copy_len); + out[copy_len] = '\0'; +} + +static bool wp_printer_accepts_jpeg(const char* printer_uri, char* err, int err_len) { + print::ProbeState probe = {}; + char probe_err[160] = {}; + if (!print::probe_printer_uri(printer_uri, &probe, probe_err, sizeof(probe_err))) + return true; + if (probe.caps.supports_jpeg) + return true; + + if (probe.caps.supported_formats[0]) { + snprintf(err, (size_t)err_len, + "Selected printer does not advertise JPEG support (supports: %s)", + probe.caps.supported_formats); + } else { + wp_status_copy(err, err_len, "Selected printer does not advertise JPEG support"); + } + return false; +} + +static bool wp_build_print_page_map(WordProcessorState* wp, + int** out_line_pages, + int** out_line_y, + int* out_page_count, + char* err, int err_len) { + if (out_line_pages) *out_line_pages = nullptr; + if (out_line_y) *out_line_y = nullptr; + if (out_page_count) *out_page_count = 0; + + wp_recompute_wrap(wp, wp_print_layout_width()); + if (wp->wrap_line_count <= 0) { + wp_status_copy(err, err_len, "failed to paginate document"); + return false; + } + + int* line_pages = (int*)montauk::malloc((uint64_t)wp->wrap_line_count * sizeof(int)); + int* line_y = (int*)montauk::malloc((uint64_t)wp->wrap_line_count * sizeof(int)); + if (!line_pages || !line_y) { + if (line_pages) montauk::mfree(line_pages); + if (line_y) montauk::mfree(line_y); + wp_status_copy(err, err_len, "out of memory while preparing print pages"); + return false; + } + + int base_y = wp->wrap_lines[0].y; + int printable_bottom = WP_PRINT_PAGE_H - WP_PRINT_MARGIN_Y; + int current_page = 0; + int current_y = WP_PRINT_MARGIN_Y; + int prev_rel_bottom = 0; + + for (int i = 0; i < wp->wrap_line_count; i++) { + WrapLine* wl = &wp->wrap_lines[i]; + int rel_y = wl->y - base_y; + if (rel_y < 0) rel_y = 0; + + int gap_before = (i == 0) ? 0 : (rel_y - prev_rel_bottom); + if (gap_before < 0) gap_before = 0; + + int line_top = current_y + gap_before; + if (line_top + wl->height > printable_bottom && current_y > WP_PRINT_MARGIN_Y) { + current_page++; + current_y = WP_PRINT_MARGIN_Y; + line_top = current_y; + } + + line_pages[i] = current_page; + line_y[i] = line_top; + current_y = line_top + wl->height; + prev_rel_bottom = rel_y + wl->height; + } + + if (out_line_pages) *out_line_pages = line_pages; + else montauk::mfree(line_pages); + if (out_line_y) *out_line_y = line_y; + else montauk::mfree(line_y); + if (out_page_count) *out_page_count = current_page + 1; + return true; +} + +static void wp_draw_print_list_marker(Canvas& c, WordProcessorState* wp, WrapLine* wl, int py) { + if (!wl->first_in_paragraph || wl->paragraph_idx < 0 || wl->paragraph_idx >= wp->paragraph_count) + return; + + ParagraphStyle* para = &wp->paragraphs[wl->paragraph_idx]; + if (para->list_type == PARA_LIST_NONE) return; + + int marker_x = WP_PRINT_MARGIN_X + para->left_indent + para->first_line_indent; + int min_x = WP_PRINT_MARGIN_X - WP_MARGIN + 4; + if (marker_x < min_x) marker_x = min_x; + + if (para->list_type == PARA_LIST_BULLET) { + fill_circle(c, marker_x + 7, py + wl->height / 2, 3, colors::BLACK); + return; + } + + if (wp->run_count <= 0) return; + StyledRun* run = &wp->runs[wl->run_idx < wp->run_count ? wl->run_idx : 0]; + TrueTypeFont* font = wp_get_font(run->font_id, run->flags); + char label[16]; + snprintf(label, sizeof(label), "%d.", wl->list_number > 0 ? wl->list_number : 1); + int top_y = py + (wl->height - run->size) / 2; + draw_text(c, font ? font : g_ui_font, marker_x, top_y, label, colors::BLACK, run->size); +} + +static bool wp_encode_print_page(WordProcessorState* wp, + const int* line_pages, + const int* line_y, + int page_index, + uint8_t** out_data, + int* out_len, + char* err, int err_len) { + if (out_data) *out_data = nullptr; + if (out_len) *out_len = 0; + + uint64_t pixel_bytes = (uint64_t)WP_PRINT_PAGE_W * WP_PRINT_PAGE_H * sizeof(uint32_t); + uint32_t* pixels = (uint32_t*)montauk::malloc(pixel_bytes); + if (!pixels) { + wp_status_copy(err, err_len, "out of memory while rendering print page"); + return false; + } + + Canvas c(pixels, WP_PRINT_PAGE_W, WP_PRINT_PAGE_H); + c.fill(colors::WHITE); + + for (int li = 0; li < wp->wrap_line_count; li++) { + if (line_pages[li] != page_index) continue; + + WrapLine* wl = &wp->wrap_lines[li]; + int py = line_y[li]; + wp_draw_print_list_marker(c, wp, wl, py); + + int chars_left = wl->char_count; + int ri = wl->run_idx; + int ro = wl->run_offset; + int x = WP_PRINT_MARGIN_X + (wl->x - WP_MARGIN); + + int line_abs_start = wp_wrap_line_start(wp, li); + int char_idx = 0; + + while (chars_left > 0 && ri < wp->run_count) { + StyledRun* r = &wp->runs[ri]; + TrueTypeFont* font = wp_get_font(r->font_id, r->flags); + if (!font || !font->valid) { + ri++; + ro = 0; + continue; + } + + GlyphCache* gc = font->get_cache(r->size); + int baseline = py + wl->baseline; + + int avail = r->len - ro; + int to_draw = avail < chars_left ? avail : chars_left; + + for (int ci = 0; ci < to_draw; ci++) { + char ch = r->text[ro + ci]; + (void)line_abs_start; + (void)char_idx; + + if (ch != '\n' && (ch >= 32 || ch < 0)) { + int adv = font->draw_char_to_buffer( + c.pixels, c.w, c.h, x, baseline, (unsigned char)ch, colors::BLACK, gc); + x += adv; + } + char_idx++; + } + + chars_left -= to_draw; + ro += to_draw; + if (ro >= r->len) { + ri++; + ro = 0; + } + } + } + + uint64_t rgb_bytes = (uint64_t)WP_PRINT_PAGE_W * WP_PRINT_PAGE_H * 3; + uint8_t* rgb = (uint8_t*)montauk::malloc(rgb_bytes); + if (!rgb) { + montauk::mfree(pixels); + wp_status_copy(err, err_len, "out of memory while encoding print page"); + return false; + } + + for (int y = 0; y < WP_PRINT_PAGE_H; y++) { + uint8_t* dst = rgb + (uint64_t)y * WP_PRINT_PAGE_W * 3; + for (int x = 0; x < WP_PRINT_PAGE_W; x++) { + uint32_t px = pixels[(uint64_t)y * WP_PRINT_PAGE_W + x]; + dst[x * 3 + 0] = (uint8_t)((px >> 16) & 0xFF); + dst[x * 3 + 1] = (uint8_t)((px >> 8) & 0xFF); + dst[x * 3 + 2] = (uint8_t)(px & 0xFF); + } + } + montauk::mfree(pixels); + + WpJpegBuffer jpeg = {}; + jpeg.capacity = 256 * 1024; + jpeg.data = (uint8_t*)montauk::malloc(jpeg.capacity); + if (!jpeg.data) { + montauk::mfree(rgb); + wp_status_copy(err, err_len, "out of memory for JPEG output"); + return false; + } + + int ok = stbi_write_jpg_to_func(wp_jpeg_write_callback, &jpeg, + WP_PRINT_PAGE_W, WP_PRINT_PAGE_H, 3, rgb, + WP_PRINT_JPEG_QUALITY); + montauk::mfree(rgb); + + if (!ok || jpeg.failed || jpeg.size <= 0) { + montauk::mfree(jpeg.data); + wp_status_copy(err, err_len, "JPEG encoding failed"); + return false; + } + + if (out_data) *out_data = jpeg.data; + else montauk::mfree(jpeg.data); + if (out_len) *out_len = jpeg.size; + return true; +} + +bool wp_print_document(WordProcessorState* wp, char* out_status, int out_status_len) { + if (out_status && out_status_len > 0) out_status[0] = '\0'; + if (!wp) { + wp_status_copy(out_status, out_status_len, "No document loaded"); + return false; + } + + char printer_uri[256] = {}; + char printer_name[128] = {}; + char msg[160] = {}; + montauk::strncpy(printer_uri, wp->preferred_printer_uri, (int)sizeof(printer_uri) - 1); + printer_uri[sizeof(printer_uri) - 1] = '\0'; + montauk::strncpy(printer_name, wp->preferred_printer_name, (int)sizeof(printer_name) - 1); + printer_name[sizeof(printer_name) - 1] = '\0'; + uint32_t copies = wp->preferred_print_copies > 0 ? wp->preferred_print_copies : 1; + + const char* job_name = wp->filename[0] ? wp->filename : "Untitled document"; + if (!dialogs::configure_print("Print Document", + printer_uri, job_name, + printer_uri, sizeof(printer_uri), + printer_name, sizeof(printer_name), + &copies, msg, sizeof(msg))) { + if (msg[0]) wp_status_copy(out_status, out_status_len, msg); + return false; + } + + montauk::strncpy(wp->preferred_printer_uri, printer_uri, (int)sizeof(wp->preferred_printer_uri) - 1); + wp->preferred_printer_uri[sizeof(wp->preferred_printer_uri) - 1] = '\0'; + montauk::strncpy(wp->preferred_printer_name, printer_name, (int)sizeof(wp->preferred_printer_name) - 1); + wp->preferred_printer_name[sizeof(wp->preferred_printer_name) - 1] = '\0'; + wp->preferred_print_copies = copies > 0 ? copies : 1; + + char err[192] = {}; + if (!wp_printer_accepts_jpeg(printer_uri, err, sizeof(err))) { + wp_status_copy(out_status, out_status_len, err); + return false; + } + + int* line_pages = nullptr; + int* line_y = nullptr; + int page_count = 0; + if (!wp_build_print_page_map(wp, &line_pages, &line_y, &page_count, err, sizeof(err))) { + wp_status_copy(out_status, out_status_len, err); + return false; + } + + char base_name[96] = {}; + wp_make_base_print_name(wp, base_name, sizeof(base_name)); + if (!base_name[0]) wp_status_copy(base_name, sizeof(base_name), "document"); + + int submitted_pages = 0; + char first_job_id[64] = {}; + + for (int page = 0; page < page_count; page++) { + uint8_t* jpeg = nullptr; + int jpeg_len = 0; + if (!wp_encode_print_page(wp, line_pages, line_y, page, &jpeg, &jpeg_len, err, sizeof(err))) { + break; + } + + char page_job_name[128]; + char source_name[128]; + if (page_count > 1) { + snprintf(page_job_name, sizeof(page_job_name), "%s (Page %d/%d)", job_name, page + 1, page_count); + snprintf(source_name, sizeof(source_name), "%s-page-%03d.jpg", base_name, page + 1); + } else { + wp_status_copy(page_job_name, sizeof(page_job_name), job_name); + snprintf(source_name, sizeof(source_name), "%s.jpg", base_name); + } + + char job_id[64] = {}; + bool ok = print::submit_document_buffer_job( + jpeg, jpeg_len, + source_name, + "image/jpeg", + printer_uri, + page_job_name, + (int)(copies > 0 ? copies : 1), + job_id, sizeof(job_id), + err, sizeof(err)); + montauk::mfree(jpeg); + if (!ok) break; + + if (submitted_pages == 0) + wp_status_copy(first_job_id, sizeof(first_job_id), job_id); + submitted_pages++; + } + + montauk::mfree(line_pages); + montauk::mfree(line_y); + + if (submitted_pages != page_count) { + if (submitted_pages > 0) { + char partial[192]; + snprintf(partial, sizeof(partial), + "Queued %d of %d pages before failing: %s", + submitted_pages, page_count, + err[0] ? err : "unknown error"); + wp_status_copy(out_status, out_status_len, partial); + } else { + wp_status_copy(out_status, out_status_len, err[0] ? err : "Failed to queue print job"); + } + return false; + } + + char summary[192]; + const char* label = printer_name[0] ? printer_name : printer_uri; + if (page_count == 1) { + if (copies > 1) { + snprintf(summary, sizeof(summary), + "Queued 1 page to %s (%u copies), job %s", + label, (unsigned)copies, first_job_id); + } else { + snprintf(summary, sizeof(summary), + "Queued print job %s to %s", + first_job_id, label); + } + } else if (copies > 1) { + snprintf(summary, sizeof(summary), + "Queued %d pages to %s (%u copies each)", + page_count, label, (unsigned)copies); + } else { + snprintf(summary, sizeof(summary), + "Queued %d pages to %s", + page_count, label); + } + + wp_status_copy(out_status, out_status_len, summary); + return true; +} diff --git a/programs/src/wordprocessor/render.cpp b/programs/src/wordprocessor/render.cpp index 6affbc2..d46bb3f 100644 --- a/programs/src/wordprocessor/render.cpp +++ b/programs/src/wordprocessor/render.cpp @@ -35,6 +35,40 @@ static void wp_draw_ui_icon_button(Canvas& c, int x, int y, int w, int h, wp_draw_ui_button(c, x, y, w, h, fallback, bg, fg, radius); } +static int wp_special_char_flyout_x() { + int dx = WP_BTN_SECTION_X + 24 - WP_SPECIAL_CHAR_FLYOUT_W; + if (dx + WP_SPECIAL_CHAR_FLYOUT_W > g_win_w) dx = g_win_w - WP_SPECIAL_CHAR_FLYOUT_W; + return dx < 0 ? 0 : dx; +} + +static void wp_fit_ui_text(const char* src, char* out, int out_len, int max_w) { + if (!src || !src[0]) { + if (out_len > 0) out[0] = '\0'; + return; + } + + montauk::strncpy(out, src, out_len - 1); + out[out_len - 1] = '\0'; + if (text_width(g_ui_font, out, fonts::UI_SIZE) <= max_w) return; + + int slen = montauk::slen(src); + for (int keep = slen; keep > 1; keep--) { + char candidate[256]; + candidate[0] = '.'; + candidate[1] = '.'; + int pos = 2; + const char* tail = src + (slen - keep); + while (*tail && pos < (int)sizeof(candidate) - 1) + candidate[pos++] = *tail++; + candidate[pos] = '\0'; + if (text_width(g_ui_font, candidate, fonts::UI_SIZE) <= max_w) { + montauk::strncpy(out, candidate, out_len - 1); + out[out_len - 1] = '\0'; + return; + } + } +} + static ParagraphStyle* wp_current_paragraph_style(WordProcessorState* wp) { if (wp->paragraph_count <= 0) { static ParagraphStyle fallback = { PARA_ALIGN_LEFT, PARA_LIST_NONE, 100, 0, 0, 0, 0, 0 }; @@ -110,7 +144,11 @@ void wp_render() { if (g_icon_save.pixels) c.icon(WP_BTN_SAVE_X + 4, 10, g_icon_save); - c.vline(WP_BTN_SAVE_X + 28, 4, 28, colors::BORDER); + c.fill_rounded_rect(WP_BTN_PRINT_X, 6, 24, 24, 3, btn_bg); + if (g_icon_print.pixels) + c.icon(WP_BTN_PRINT_X + 4, 10, g_icon_print); + + c.vline(WP_BTN_PRINT_X + 28, 4, 28, colors::BORDER); wp_draw_ui_icon_button(c, WP_BTN_UNDO_X, 6, 24, 24, g_icon_undo, "U", btn_bg, colors::TEXT_COLOR); wp_draw_ui_icon_button(c, WP_BTN_REDO_X, 6, 24, 24, g_icon_redo, "R", btn_bg, colors::TEXT_COLOR); @@ -167,7 +205,8 @@ void wp_render() { c.vline(WP_BTN_SECTION_X - 4, 4, 28, colors::BORDER); - c.fill_rounded_rect(WP_BTN_SECTION_X, 6, 24, 24, 3, btn_bg); + Color special_bg = wp->special_char_flyout_open ? btn_active : btn_bg; + c.fill_rounded_rect(WP_BTN_SECTION_X, 6, 24, 24, 3, special_bg); { char section[2] = { (char)0xA7, '\0' }; TrueTypeFont* font = wp_get_font(FONT_ROBOTO, 0); @@ -328,18 +367,25 @@ void wp_render() { c.fill_rect(0, status_y, c.w, WP_STATUS_H, Color::from_rgb(0x2B, 0x3E, 0x50)); int status_text_y = status_y + (WP_STATUS_H - sfh) / 2; - char status_left[192]; + char status_left[320]; const char* name = wp->filename[0] ? wp->filename : "Untitled"; snprintf(status_left, sizeof(status_left), " %s%s | %s %dpt | %s | %s | %d%%", name, wp->modified ? " *" : "", WP_FONT_NAMES[wp->cur_font_id < FONT_COUNT ? wp->cur_font_id : 0], (int)wp->cur_size, wp_align_label(cur_para->align), wp_list_label(cur_para->list_type), (int)cur_para->line_spacing); - wp_draw_ui_text(c, 4, status_text_y, status_left, colors::PANEL_TEXT); char status_right[32]; snprintf(status_right, sizeof(status_right), "%d chars ", wp->total_text_len); int sr_w = text_width(g_ui_font, status_right, fonts::UI_SIZE); + char status_combined[320]; + if (wp->status_msg[0]) snprintf(status_combined, sizeof(status_combined), "%s | %s", status_left, wp->status_msg); + else montauk::strncpy(status_combined, status_left, (int)sizeof(status_combined) - 1); + status_combined[sizeof(status_combined) - 1] = '\0'; + + char status_fit[320]; + wp_fit_ui_text(status_combined, status_fit, sizeof(status_fit), c.w - sr_w - 12); + wp_draw_ui_text(c, 4, status_text_y, status_fit, colors::PANEL_TEXT); wp_draw_ui_text(c, c.w - sr_w - 4, status_text_y, status_right, colors::PANEL_TEXT); if (wp->font_dropdown_open) { @@ -390,4 +436,24 @@ void wp_render() { wp_draw_ui_text(c, dx + 8, iy + (24 - sfh) / 2, spacing, colors::TEXT_COLOR); } } + + if (wp->special_char_flyout_open) { + int dx = wp_special_char_flyout_x(); + int dy = WP_TOOLBAR_H; + int dw = WP_SPECIAL_CHAR_FLYOUT_W; + int dh = WP_SPECIAL_CHAR_OPTION_COUNT * WP_SPECIAL_CHAR_ROW_H + 4; + TrueTypeFont* glyph_font = wp_get_font(FONT_ROBOTO, 0); + c.fill_rect(dx, dy, dw, dh, colors::MENU_BG); + c.rect(dx, dy, dw, dh, colors::BORDER); + for (int i = 0; i < WP_SPECIAL_CHAR_OPTION_COUNT; i++) { + int iy = dy + 2 + i * WP_SPECIAL_CHAR_ROW_H; + char glyph[2] = { (char)WP_SPECIAL_CHAR_OPTIONS[i].code, '\0' }; + c.fill_rect(dx + 2, iy, dw - 4, WP_SPECIAL_CHAR_ROW_H - 2, colors::MENU_BG); + draw_text(c, glyph_font ? glyph_font : g_ui_font, + dx + 8, iy + (WP_SPECIAL_CHAR_ROW_H - sfh) / 2, + glyph, colors::TEXT_COLOR, fonts::UI_SIZE); + wp_draw_ui_text(c, dx + 32, iy + (WP_SPECIAL_CHAR_ROW_H - sfh) / 2, + WP_SPECIAL_CHAR_OPTIONS[i].label, colors::TEXT_COLOR); + } + } } diff --git a/programs/src/wordprocessor/wordprocessor.hpp b/programs/src/wordprocessor/wordprocessor.hpp index 7e9c0c3..004d20c 100644 --- a/programs/src/wordprocessor/wordprocessor.hpp +++ b/programs/src/wordprocessor/wordprocessor.hpp @@ -21,8 +21,8 @@ extern "C" { using namespace gui; -static constexpr int INIT_W = 640; -static constexpr int INIT_H = 480; +static constexpr int INIT_W = 900; +static constexpr int INIT_H = 600; static constexpr int WP_TOOLBAR_H = 36; static constexpr int WP_PATHBAR_H = 32; static constexpr int WP_STATUS_H = 24; @@ -41,24 +41,27 @@ static constexpr int WP_LIST_HANGING = -18; static constexpr int WP_LIST_MARKER_W = 20; static constexpr int WP_BTN_OPEN_X = 4; static constexpr int WP_BTN_SAVE_X = 32; -static constexpr int WP_BTN_UNDO_X = 66; -static constexpr int WP_BTN_REDO_X = 94; -static constexpr int WP_BTN_BOLD_X = 128; -static constexpr int WP_BTN_ITALIC_X = 156; -static constexpr int WP_FONT_DD_X = 188; -static constexpr int WP_FONT_DD_W = 84; -static constexpr int WP_SIZE_DD_X = 278; -static constexpr int WP_SIZE_DD_W = 40; -static constexpr int WP_BTN_ALIGN_L_X = 330; -static constexpr int WP_BTN_ALIGN_C_X = 358; -static constexpr int WP_BTN_ALIGN_R_X = 386; -static constexpr int WP_BTN_BULLET_X = 418; -static constexpr int WP_BTN_NUMBER_X = 446; -static constexpr int WP_BTN_OUTDENT_X = 482; -static constexpr int WP_BTN_INDENT_X = 510; -static constexpr int WP_LINE_DD_X = 544; -static constexpr int WP_LINE_DD_W = 56; -static constexpr int WP_BTN_SECTION_X = 608; +static constexpr int WP_BTN_PRINT_X = 60; +static constexpr int WP_BTN_UNDO_X = 94; +static constexpr int WP_BTN_REDO_X = 122; +static constexpr int WP_BTN_BOLD_X = 156; +static constexpr int WP_BTN_ITALIC_X = 184; +static constexpr int WP_FONT_DD_X = 216; +static constexpr int WP_FONT_DD_W = 74; +static constexpr int WP_SIZE_DD_X = 296; +static constexpr int WP_SIZE_DD_W = 36; +static constexpr int WP_BTN_ALIGN_L_X = 344; +static constexpr int WP_BTN_ALIGN_C_X = 372; +static constexpr int WP_BTN_ALIGN_R_X = 400; +static constexpr int WP_BTN_BULLET_X = 432; +static constexpr int WP_BTN_NUMBER_X = 460; +static constexpr int WP_BTN_OUTDENT_X = 496; +static constexpr int WP_BTN_INDENT_X = 524; +static constexpr int WP_LINE_DD_X = 558; +static constexpr int WP_LINE_DD_W = 48; +static constexpr int WP_BTN_SECTION_X = 614; +static constexpr int WP_SPECIAL_CHAR_ROW_H = 24; +static constexpr int WP_SPECIAL_CHAR_FLYOUT_W = 144; static constexpr int FONT_ROBOTO = 0; static constexpr int FONT_NOTOSERIF = 1; @@ -79,6 +82,21 @@ static constexpr int WP_SIZE_OPTION_COUNT = 8; inline constexpr int WP_LINE_SPACING_OPTIONS[] = { 100, 125, 150, 200 }; static constexpr int WP_LINE_SPACING_OPTION_COUNT = 4; +struct WpSpecialCharOption { + uint8_t code; + const char* label; +}; + +inline constexpr WpSpecialCharOption WP_SPECIAL_CHAR_OPTIONS[] = { + { 0xA7, "Section" }, + { 0xB6, "Pilcrow" }, + { 0xA9, "Copyright" }, + { 0xAE, "Registered" }, + { 0xB0, "Degree" }, + { 0xB1, "Plus/Minus" }, +}; +static constexpr int WP_SPECIAL_CHAR_OPTION_COUNT = 6; + enum ParagraphAlign : uint8_t { PARA_ALIGN_LEFT = 0, PARA_ALIGN_CENTER = 1, @@ -266,6 +284,10 @@ struct WordProcessorState { bool modified; char filepath[256]; char filename[64]; + char preferred_printer_uri[256]; + char preferred_printer_name[128]; + uint32_t preferred_print_copies; + char status_msg[160]; bool show_pathbar; bool pathbar_save_mode; @@ -276,6 +298,7 @@ struct WordProcessorState { bool font_dropdown_open; bool size_dropdown_open; bool line_spacing_dropdown_open; + bool special_char_flyout_open; UndoSnapshot undo[WP_UNDO_MAX]; int undo_count; @@ -289,6 +312,7 @@ extern WordProcessorState g_wp; extern WPFontTable g_wp_fonts; extern SvgIcon g_icon_folder; extern SvgIcon g_icon_save; +extern SvgIcon g_icon_print; extern SvgIcon g_icon_undo; extern SvgIcon g_icon_redo; extern SvgIcon g_icon_align_left; @@ -357,6 +381,8 @@ void wp_history_mark_saved(WordProcessorState* wp); bool wp_undo(WordProcessorState* wp); bool wp_redo(WordProcessorState* wp); +bool wp_print_document(WordProcessorState* wp, char* out_status, int out_status_len); + void wp_render(); void wp_handle_mouse(const Montauk::WinEvent& ev); void wp_handle_key(const Montauk::KeyEvent& key);