Reimplement aven's cavern log window
[survex.git] / src / cavernlog.cc
blobc45547d16dc05b134aaf8b106648fd10c455f11d
1 /* cavernlog.cc
2 * Run cavern inside an Aven window
4 * Copyright (C) 2005-2024 Olly Betts
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
16 * You should have received a copy of the GNU General Public License
17 * along with this program; if not, write to the Free Software
18 * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
21 #include <config.h>
23 #include "aven.h"
24 #include "cavernlog.h"
25 #include "filename.h"
26 #include "mainfrm.h"
27 #include "message.h"
28 #include "osalloc.h"
30 #include <errno.h>
31 #include <stdio.h>
32 #include <stdlib.h>
34 #include <sys/time.h>
35 #include <sys/types.h>
36 #include <unistd.h>
38 #include <wx/process.h>
40 #define GVIM_COMMAND "gvim +'call cursor($l,$c)' $f"
41 #define VIM_COMMAND "x-terminal-emulator -e vim +'call cursor($l,$c)' $f"
42 #define NVIM_COMMAND "x-terminal-emulator -e nvim +'call cursor($l,$c)' $f"
43 #define GEDIT_COMMAND "gedit $f +$l:$c"
44 // Pluma currently ignores the column, but include it assuming some future
45 // version will add support.
46 #define PLUMA_COMMAND "pluma +$l:$c $f"
47 #define EMACS_COMMAND "x-terminal-emulator -e emacs +$l:$c $f"
48 #define NANO_COMMAND "x-terminal-emulator -e nano +$l,$c $f"
49 #define JED_COMMAND "x-terminal-emulator -e jed $f -g $l"
50 #define KATE_COMMAND "kate -l $l -c $c $f"
52 #ifdef __WXMSW__
53 # define DEFAULT_EDITOR_COMMAND "notepad $f"
54 #elif defined __WXMAC__
55 # define DEFAULT_EDITOR_COMMAND "open -t $f"
56 #else
57 # define DEFAULT_EDITOR_COMMAND VIM_COMMAND
58 #endif
60 enum { LOG_REPROCESS = 1234, LOG_SAVE = 1235 };
62 static const wxString badutf8_html(
63 wxT("<span style=\"color:white;background-color:red;\">&#xfffd;</span>"));
64 static const wxString badutf8(wxUniChar(0xfffd));
66 // New event type for signalling cavern output to process.
67 wxDEFINE_EVENT(EVT_CAVERN_OUTPUT, wxCommandEvent);
69 void
70 CavernLogWindow::CheckForOutput(bool immediate)
72 timer.Stop();
73 if (cavern_out == NULL) return;
75 wxInputStream * in = cavern_out->GetInputStream();
77 if (!in->CanRead()) {
78 timer.StartOnce();
79 return;
82 size_t real_size = log_txt.size();
83 size_t allow = 1024;
84 log_txt.resize(real_size + allow);
85 in->Read(&log_txt[real_size], allow);
86 size_t n = in->LastRead();
87 log_txt.resize(real_size + n);
88 if (n) {
89 if (immediate) {
90 ProcessCavernOutput();
91 } else {
92 QueueEvent(new wxCommandEvent(EVT_CAVERN_OUTPUT));
97 int
98 CavernLogWindow::OnPaintButton(wxButton* b, int x)
100 if (b) {
101 x -= 4;
102 const wxSize& bsize = b->GetSize();
103 x -= bsize.x;
104 b->SetSize(x, 4, bsize.x, bsize.y);
105 x -= 4;
107 return x;
110 void
111 CavernLogWindow::OnPaint(wxPaintEvent&)
113 wxPaintDC dc(this);
114 wxFont font = dc.GetFont();
115 wxFont bold_font = font.Bold();
116 wxFont underlined_font = font.Underlined();
117 const wxRegion& region = GetUpdateRegion();
118 const wxRect& rect = region.GetBox();
119 int scroll_x = 0, scroll_y = 0;
120 GetViewStart(&scroll_x, &scroll_y);
121 int fsize = dc.GetFont().GetPixelSize().GetHeight();
122 int limit = min((rect.y + rect.height + fsize - 1) / fsize + scroll_y, int(line_info.size()) - 1);
123 for (int i = max(rect.y / fsize, scroll_y); i <= limit ; ++i) {
124 LineInfo& info = line_info[i];
125 // Leave a small margin to the left.
126 int x = fsize / 2 - scroll_x * fsize;
127 int y = (i - scroll_y) * fsize;
128 unsigned offset = info.start_offset;
129 unsigned len = info.len;
130 if (info.link_len) {
131 dc.SetFont(underlined_font);
132 dc.SetTextForeground(wxColour(192, 0, 192));
133 wxString link = wxString::FromUTF8(&log_txt[offset], info.link_len);
134 offset += info.link_len;
135 len -= info.link_len;
136 dc.DrawText(link, x, y);
137 x += info.link_pixel_width;
138 dc.SetFont(font);
140 if (info.colour_len) {
141 dc.SetTextForeground(*wxBLACK);
143 size_t s_len = info.start_offset + info.colour_start - offset;
144 wxString s = wxString::FromUTF8(&log_txt[offset], s_len);
145 offset += s_len;
146 len -= s_len;
147 dc.DrawText(s, x, y);
148 x += dc.GetTextExtent(s).GetWidth();
150 switch (info.colour) {
151 case LOG_ERROR:
152 dc.SetTextForeground(*wxRED);
153 break;
154 case LOG_WARNING:
155 dc.SetTextForeground(wxColour(0xf2, 0x8C, 0x28));
156 break;
157 case LOG_INFO:
158 dc.SetTextForeground(*wxBLUE);
159 break;
161 dc.SetFont(bold_font);
162 wxString d = wxString::FromUTF8(&log_txt[offset], info.colour_len);
163 offset += info.colour_len;
164 len -= info.colour_len;
165 dc.DrawText(d, x, y);
166 x += dc.GetTextExtent(d).GetWidth();
167 dc.SetFont(font);
169 dc.SetTextForeground(*wxBLACK);
170 dc.DrawText(wxString::FromUTF8(&log_txt[offset], len), x, y);
172 int x = GetClientSize().x;
173 x = OnPaintButton(ok_button, x);
174 x = OnPaintButton(reprocess_button, x);
175 OnPaintButton(save_button, x);
178 BEGIN_EVENT_TABLE(CavernLogWindow, wxScrolledWindow)
179 EVT_BUTTON(LOG_REPROCESS, CavernLogWindow::OnReprocess)
180 EVT_BUTTON(LOG_SAVE, CavernLogWindow::OnSave)
181 EVT_BUTTON(wxID_OK, CavernLogWindow::OnOK)
182 EVT_COMMAND(wxID_ANY, EVT_CAVERN_OUTPUT, CavernLogWindow::OnCavernOutput)
183 EVT_IDLE(CavernLogWindow::OnIdle)
184 EVT_TIMER(wxID_ANY, CavernLogWindow::OnTimer)
185 EVT_PAINT(CavernLogWindow::OnPaint)
186 EVT_MOTION(CavernLogWindow::OnMouseMove)
187 EVT_LEFT_UP(CavernLogWindow::OnLinkClicked)
188 EVT_END_PROCESS(wxID_ANY, CavernLogWindow::OnEndProcess)
189 END_EVENT_TABLE()
191 wxString escape_for_shell(wxString s, bool protect_dash)
193 #ifdef __WXMSW__
194 // Correct quoting rules are insane:
196 // http://blogs.msdn.com/b/twistylittlepassagesallalike/archive/2011/04/23/everyone-quotes-arguments-the-wrong-way.aspx
198 // Thankfully wxExecute passes the command string to CreateProcess(), so
199 // at least we don't need to quote for cmd.exe too.
200 if (s.empty() || s.find_first_of(wxT(" \"\t\n\v")) != s.npos) {
201 // Need to quote.
202 s.insert(0, wxT('"'));
203 for (size_t p = 1; p < s.size(); ++p) {
204 size_t backslashes = 0;
205 while (s[p] == wxT('\\')) {
206 ++backslashes;
207 if (++p == s.size()) {
208 // Escape all the backslashes, since they're before
209 // the closing quote we add below.
210 s.append(backslashes, wxT('\\'));
211 goto done;
215 if (s[p] == wxT('"')) {
216 // Escape any preceding backslashes and this quote.
217 s.insert(p, backslashes + 1, wxT('\\'));
218 p += backslashes + 1;
221 done:
222 s.append(wxT('"'));
224 #else
225 size_t p = 0;
226 if (protect_dash && !s.empty() && s[0u] == '-') {
227 // If the filename starts with a '-', protect it from being
228 // treated as an option by prepending "./".
229 s.insert(0, wxT("./"));
230 p = 2;
232 while (p < s.size()) {
233 // Exclude a few safe characters which are common in filenames
234 if (!isalnum((unsigned char)s[p]) && strchr("/._-", s[p]) == NULL) {
235 s.insert(p, 1, wxT('\\'));
236 ++p;
238 ++p;
240 #endif
241 return s;
244 wxString get_command_path(const wxChar * command_name)
246 #ifdef __WXMSW__
247 wxString cmd;
249 DWORD len = 256;
250 wchar_t *buf = NULL;
251 while (1) {
252 DWORD got;
253 buf = (wchar_t*)osrealloc(buf, len * 2);
254 got = GetModuleFileNameW(NULL, buf, len);
255 if (got < len) break;
256 len += len;
258 /* Strange Win32 nastiness - strip prefix "\\?\" if present */
259 wchar_t *start = buf;
260 if (wcsncmp(start, L"\\\\?\\", 4) == 0) start += 4;
261 wchar_t * slash = wcsrchr(start, L'\\');
262 if (slash) {
263 cmd.assign(start, slash - start + 1);
265 osfree(buf);
267 #else
268 wxString cmd = wxString::FromUTF8(msg_exepth());
269 #endif
270 cmd += command_name;
271 return cmd;
274 CavernLogWindow::CavernLogWindow(MainFrm * mainfrm_, const wxString & survey_, wxWindow * parent)
275 : wxScrolledWindow(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize,
276 wxFULL_REPAINT_ON_RESIZE),
277 mainfrm(mainfrm_),
278 survey(survey_),
279 timer(this)
283 CavernLogWindow::~CavernLogWindow()
285 timer.Stop();
286 if (cavern_out) {
287 wxEndBusyCursor();
288 cavern_out->Detach();
292 void
293 CavernLogWindow::OnMouseMove(wxMouseEvent& e)
295 const auto& pos = e.GetPosition();
296 int fsize = GetFont().GetPixelSize().GetHeight();
297 int scroll_x = 0, scroll_y = 0;
298 GetViewStart(&scroll_x, &scroll_y);
299 unsigned line = pos.y / fsize + scroll_y;
300 unsigned x = pos.x + scroll_x * fsize - fsize / 2;
301 if (line < line_info.size() && x <= line_info[line].link_pixel_width) {
302 SetCursor(wxCursor(wxCURSOR_HAND));
303 } else {
304 SetCursor(wxNullCursor);
308 void
309 CavernLogWindow::OnLinkClicked(wxMouseEvent& e)
311 const auto& pos = e.GetPosition();
312 int fsize = GetFont().GetPixelSize().GetHeight();
313 int scroll_x = 0, scroll_y = 0;
314 GetViewStart(&scroll_x, &scroll_y);
315 unsigned line = pos.y / fsize + scroll_y;
316 unsigned x = pos.x + scroll_x * fsize - fsize / 2;
317 if (!(line < line_info.size() && x <= line_info[line].link_pixel_width))
318 return;
320 const char* cur = &log_txt[line_info[line].start_offset];
321 size_t link_len = line_info[line].link_len;
322 size_t colon = link_len;
323 while (colon > 1 && (unsigned)(cur[--colon] - '0') <= 9) { }
324 size_t colon2 = colon;
325 while (colon > 1 && (unsigned)(cur[--colon] - '0') <= 9) { }
326 if (cur[colon] != ':') {
327 colon = colon2;
328 colon2 = link_len;
331 wxString cmd;
332 wxChar * p = wxGetenv(wxT("SURVEXEDITOR"));
333 if (p) {
334 cmd = p;
335 if (!cmd.find(wxT("$f"))) {
336 cmd += wxT(" $f");
338 } else {
339 p = wxGetenv(wxT("VISUAL"));
340 if (!p) p = wxGetenv(wxT("EDITOR"));
341 if (!p) {
342 cmd = wxT(DEFAULT_EDITOR_COMMAND);
343 } else {
344 cmd = p;
345 if (cmd == "gvim") {
346 cmd = wxT(GVIM_COMMAND);
347 } else if (cmd == "vim") {
348 cmd = wxT(VIM_COMMAND);
349 } else if (cmd == "nvim") {
350 cmd = wxT(NVIM_COMMAND);
351 } else if (cmd == "gedit") {
352 cmd = wxT(GEDIT_COMMAND);
353 } else if (cmd == "pluma") {
354 cmd = wxT(PLUMA_COMMAND);
355 } else if (cmd == "emacs") {
356 cmd = wxT(EMACS_COMMAND);
357 } else if (cmd == "nano") {
358 cmd = wxT(NANO_COMMAND);
359 } else if (cmd == "jed") {
360 cmd = wxT(JED_COMMAND);
361 } else if (cmd == "kate") {
362 cmd = wxT(KATE_COMMAND);
363 } else {
364 // Escape any $.
365 cmd.Replace(wxT("$"), wxT("$$"));
366 cmd += wxT(" $f");
370 size_t i = 0;
371 while ((i = cmd.find(wxT('$'), i)) != wxString::npos) {
372 if (++i >= cmd.size()) break;
373 switch ((int)cmd[i]) {
374 case wxT('$'):
375 cmd.erase(i, 1);
376 break;
377 case wxT('f'): {
378 wxString f = escape_for_shell(wxString(cur, colon), true);
379 cmd.replace(i - 1, 2, f);
380 i += f.size() - 1;
381 break;
383 case wxT('l'): {
384 wxString l = escape_for_shell(wxString(cur + colon + 1, colon2 - colon - 1));
385 cmd.replace(i - 1, 2, l);
386 i += l.size() - 1;
387 break;
389 case wxT('c'): {
390 wxString l;
391 if (colon2 == link_len)
392 l = wxT("0");
393 else
394 l = escape_for_shell(wxString(cur + colon2 + 1, link_len - colon2 - 1));
395 cmd.replace(i - 1, 2, l);
396 i += l.size() - 1;
397 break;
399 default:
400 ++i;
404 if (wxExecute(cmd, wxEXEC_ASYNC|wxEXEC_MAKE_GROUP_LEADER) >= 0)
405 return;
407 wxString m;
408 // TRANSLATORS: %s is replaced by the command we attempted to run.
409 m.Printf(wmsg(/*Couldn’t run external command: “%s”*/17), cmd.c_str());
410 m += wxT(" (");
411 m += wxString::FromUTF8(strerror(errno));
412 m += wxT(')');
413 wxGetApp().ReportError(m);
416 void
417 CavernLogWindow::process(const wxString &file)
419 timer.Stop();
420 if (cavern_out) {
421 cavern_out->Detach();
422 cavern_out = NULL;
423 } else {
424 wxBeginBusyCursor();
427 SetFocus();
428 filename = file;
430 info_count = 0;
431 link_count = 0;
432 log_txt.resize(0);
433 line_info.resize(0);
434 // Reserve enough that we won't need to grow the allocations in normal cases.
435 log_txt.reserve(16384);
436 line_info.reserve(256);
437 ptr = 0;
438 save_button = nullptr;
439 reprocess_button = nullptr;
440 ok_button = nullptr;
441 DestroyChildren();
442 SetVirtualSize(0, 0);
444 #ifdef __WXMSW__
445 SetEnvironmentVariable(wxT("SURVEX_UTF8"), wxT("1"));
446 #else
447 setenv("SURVEX_UTF8", "1", 1);
448 #endif
450 wxString escaped_file = escape_for_shell(file, true);
451 wxString cmd = get_command_path(L"cavern");
452 cmd = escape_for_shell(cmd, false);
453 cmd += wxT(" -o ");
454 cmd += escaped_file;
455 cmd += wxT(' ');
456 cmd += escaped_file;
458 cavern_out = wxProcess::Open(cmd);
459 if (!cavern_out) {
460 wxString m;
461 m.Printf(wmsg(/*Couldn’t run external command: “%s”*/17), cmd.c_str());
462 m += wxT(" (");
463 m += wxString::FromUTF8(strerror(errno));
464 m += wxT(')');
465 wxGetApp().ReportError(m);
466 return;
469 // We want to receive the wxProcessEvent when cavern exits.
470 cavern_out->SetNextHandler(this);
472 // Check for output after 500ms if we don't get an idle event sooner.
473 timer.StartOnce(500);
476 void
477 CavernLogWindow::ProcessCavernOutput()
479 // ptr gives the start of the first line we've not yet processed.
481 size_t nl;
482 while ((nl = log_txt.find('\n', ptr)) != std::string::npos) {
483 if (nl == ptr || (nl - ptr == 1 && log_txt[ptr] == '\r')) {
484 // Don't show empty lines in the window.
485 ptr = nl + 1;
486 continue;
488 size_t line_len = nl - ptr - (log_txt[nl - 1] == '\r');
489 // FIXME: Avoid copy, use string_view?
490 string cur(log_txt, ptr, line_len);
491 if (log_txt[ptr] == ' ') {
492 if (expecting_caret_line) {
493 // FIXME: Check the line is only space, `^` and `~`?
494 // Otherwise an error without caret info followed
495 // by an error which contains a '^' gets
496 // mishandled...
497 size_t caret = cur.rfind('^');
498 if (caret != wxString::npos) {
499 size_t tilde = cur.rfind('~');
500 if (tilde == wxString::npos || tilde < caret) {
501 tilde = caret;
503 line_info.back().colour = line_info[line_info.size() - 2].colour;
504 line_info.back().colour_start = caret;
505 line_info.back().colour_len = tilde - caret + 1;
506 expecting_caret_line = false;
507 ptr = nl + 1;
508 continue;
511 expecting_caret_line = true;
513 line_info.emplace_back(ptr);
514 line_info.back().len = line_len;
515 size_t colon = cur.find(": ");
516 if (colon != wxString::npos) {
517 size_t link_len = colon;
518 while (colon > 1 && (unsigned)(cur[--colon] - '0') <= 9) { }
519 if (cur[colon] == ':') {
520 line_info.back().link_len = link_len;
522 static string info_marker = string(msg(/*info*/485)) + ':';
523 static string warning_marker = string(msg(/*warning*/4)) + ':';
524 static string error_marker = string(msg(/*error*/93)) + ':';
526 size_t offset = link_len + 2;
527 if (cur.compare(offset, info_marker.size(), info_marker) == 0) {
528 // Show "info" marker in blue.
529 ++info_count;
530 line_info.back().colour = LOG_INFO;
531 line_info.back().colour_start = offset;
532 line_info.back().colour_len = info_marker.size() - 1;
533 } else if (cur.compare(offset, warning_marker.size(), warning_marker) == 0) {
534 // Show "warning" marker in orange.
535 line_info.back().colour = LOG_WARNING;
536 line_info.back().colour_start = offset;
537 line_info.back().colour_len = warning_marker.size() - 1;
538 } else if (cur.compare(offset, error_marker.size(), error_marker) == 0) {
539 // Show "error" marker in red.
540 line_info.back().colour = LOG_ERROR;
541 line_info.back().colour_start = offset;
542 line_info.back().colour_len = error_marker.size() - 1;
544 ++link_count;
548 int fsize = GetFont().GetPixelSize().GetHeight();
549 SetScrollRate(fsize, fsize);
551 auto& info = line_info.back();
552 info.link_pixel_width = GetTextExtent(wxString(&log_txt[ptr], info.link_len)).GetWidth();
553 auto rest_pixel_width = GetTextExtent(wxString(&log_txt[ptr + info.link_len], info.len - info.link_len)).GetWidth();
554 int width = max(GetVirtualSize().GetWidth(),
555 int(fsize + info.link_pixel_width + rest_pixel_width));
556 int height = line_info.size();
557 SetVirtualSize(width, height * fsize);
558 if (!link_count) {
559 // Auto-scroll until the first diagnostic.
560 int scroll_x = 0, scroll_y = 0;
561 GetViewStart(&scroll_x, &scroll_y);
562 int xs, ys;
563 GetClientSize(&xs, &ys);
564 Scroll(scroll_x, line_info.size() * fsize - ys);
566 ptr = nl + 1;
570 void
571 CavernLogWindow::OnEndProcess(wxProcessEvent & evt)
573 bool cavern_success = evt.GetExitCode() == 0;
575 // Read and process any remaining buffered output.
576 wxInputStream* in = cavern_out->GetInputStream();
577 while (!in->Eof()) {
578 CheckForOutput(true);
581 wxEndBusyCursor();
583 delete cavern_out;
584 cavern_out = NULL;
586 // Initially place buttons off the right of the window - they get moved to
587 // the desired position by OnPaintButton().
588 wxPoint off_right(GetSize().x, 0);
589 /* TRANSLATORS: Label for button in aven’s cavern log window which
590 * allows the user to save the log to a file. */
591 save_button = new wxButton(this, LOG_SAVE, wmsg(/*&Save Log*/446), off_right);
593 /* TRANSLATORS: Label for button in aven’s cavern log window which
594 * causes the survey data to be reprocessed. */
595 reprocess_button = new wxButton(this, LOG_REPROCESS, wmsg(/*&Reprocess*/184), off_right);
597 if (cavern_success) {
598 ok_button = new wxButton(this, wxID_OK, wxString(), off_right);
599 ok_button->SetDefault();
602 Refresh();
603 if (!cavern_success) {
604 return;
607 init_done = false;
610 wxString file3d(filename, 0, filename.length() - 3);
611 file3d.append(wxT("3d"));
612 if (!mainfrm->LoadData(file3d, survey)) {
613 return;
617 // Don't stay on log if there there are only "info" diagnostics.
618 if (link_count == info_count) {
619 wxCommandEvent dummy;
620 OnOK(dummy);
624 void
625 CavernLogWindow::OnReprocess(wxCommandEvent &)
627 process(filename);
630 void
631 CavernLogWindow::OnSave(wxCommandEvent &)
633 wxString filelog(filename, 0, filename.length() - 3);
634 #ifdef __WXMSW__
635 // We need to consistently use `\` here.
636 filelog.Replace("/", "\\");
637 #endif
638 filelog += wxT("log");
639 #ifdef __WXMOTIF__
640 wxString ext(wxT("*.log"));
641 #else
642 /* TRANSLATORS: Log files from running cavern (extension .log) */
643 wxString ext = wmsg(/*Log files*/447);
644 ext += wxT("|*.log");
645 #endif
646 wxFileDialog dlg(this, wmsg(/*Select an output filename*/319),
647 wxString(), filelog, ext,
648 wxFD_SAVE|wxFD_OVERWRITE_PROMPT);
649 if (dlg.ShowModal() != wxID_OK) return;
650 filelog = dlg.GetPath();
651 FILE * fh_log = wxFopen(filelog, wxT("w"));
652 if (!fh_log) {
653 wxGetApp().ReportError(wxString::Format(wmsg(/*Error writing to file “%s”*/110), filelog.c_str()));
654 return;
656 fwrite(log_txt.data(), log_txt.size(), 1, fh_log);
657 fclose(fh_log);
660 void
661 CavernLogWindow::OnOK(wxCommandEvent &)
663 if (init_done) {
664 mainfrm->HideLog(this);
665 } else {
666 mainfrm->InitialiseAfterLoad(filename, survey);
667 init_done = true;