Serenity Operating System
1/*
2 * Copyright (c) 2018-2020, Andreas Kling <kling@serenityos.org>
3 * All rights reserved.
4 *
5 * Redistribution and use in source and binary forms, with or without
6 * modification, are permitted provided that the following conditions are met:
7 *
8 * 1. Redistributions of source code must retain the above copyright notice, this
9 * list of conditions and the following disclaimer.
10 *
11 * 2. Redistributions in binary form must reproduce the above copyright notice,
12 * this list of conditions and the following disclaimer in the documentation
13 * and/or other materials provided with the distribution.
14 *
15 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
25 */
26
27#include "TextEditorWidget.h"
28#include <AK/Optional.h>
29#include <AK/StringBuilder.h>
30#include <AK/URL.h>
31#include <LibCore/File.h>
32#include <LibCore/MimeData.h>
33#include <LibGUI/AboutDialog.h>
34#include <LibGUI/Action.h>
35#include <LibGUI/ActionGroup.h>
36#include <LibGUI/BoxLayout.h>
37#include <LibGUI/Button.h>
38#include <LibGUI/CppSyntaxHighlighter.h>
39#include <LibGUI/FilePicker.h>
40#include <LibGUI/FontDatabase.h>
41#include <LibGUI/JSSyntaxHighlighter.h>
42#include <LibGUI/Menu.h>
43#include <LibGUI/MenuBar.h>
44#include <LibGUI/MessageBox.h>
45#include <LibGUI/StatusBar.h>
46#include <LibGUI/TextBox.h>
47#include <LibGUI/TextEditor.h>
48#include <LibGUI/ToolBar.h>
49#include <LibGfx/Font.h>
50#include <string.h>
51
52TextEditorWidget::TextEditorWidget()
53{
54 set_layout<GUI::VerticalBoxLayout>();
55 layout()->set_spacing(0);
56
57 auto& toolbar = add<GUI::ToolBar>();
58 m_editor = add<GUI::TextEditor>();
59 m_editor->set_ruler_visible(true);
60 m_editor->set_automatic_indentation_enabled(true);
61 m_editor->set_line_wrapping_enabled(true);
62
63 m_editor->on_change = [this] {
64 // Do not mark as diry on the first change (When document is first opened.)
65 if (m_document_opening) {
66 m_document_opening = false;
67 return;
68 }
69
70 bool was_dirty = m_document_dirty;
71 m_document_dirty = true;
72 if (!was_dirty)
73 update_title();
74 };
75
76 m_find_replace_widget = add<GUI::Widget>();
77 m_find_replace_widget->set_fill_with_background_color(true);
78 m_find_replace_widget->set_size_policy(GUI::SizePolicy::Fill, GUI::SizePolicy::Fixed);
79 m_find_replace_widget->set_preferred_size(0, 48);
80 m_find_replace_widget->set_layout<GUI::VerticalBoxLayout>();
81 m_find_replace_widget->layout()->set_margins({ 2, 2, 2, 4 });
82 m_find_replace_widget->set_visible(false);
83
84 m_find_widget = m_find_replace_widget->add<GUI::Widget>();
85 m_find_widget->set_fill_with_background_color(true);
86 m_find_widget->set_size_policy(GUI::SizePolicy::Fill, GUI::SizePolicy::Fixed);
87 m_find_widget->set_preferred_size(0, 22);
88 m_find_widget->set_layout<GUI::HorizontalBoxLayout>();
89 m_find_widget->set_visible(false);
90
91 m_replace_widget = m_find_replace_widget->add<GUI::Widget>();
92 m_replace_widget->set_fill_with_background_color(true);
93 m_replace_widget->set_size_policy(GUI::SizePolicy::Fill, GUI::SizePolicy::Fixed);
94 m_replace_widget->set_preferred_size(0, 22);
95 m_replace_widget->set_layout<GUI::HorizontalBoxLayout>();
96 m_replace_widget->set_visible(false);
97
98 m_find_textbox = m_find_widget->add<GUI::TextBox>();
99 m_replace_textbox = m_replace_widget->add<GUI::TextBox>();
100
101 m_find_next_action = GUI::Action::create("Find next", { Mod_Ctrl, Key_G }, [&](auto&) {
102 auto needle = m_find_textbox->text();
103 if (needle.is_empty()) {
104 dbg() << "find_next(\"\")";
105 return;
106 }
107 auto found_range = m_editor->document().find_next(needle, m_editor->normalized_selection().end());
108 dbg() << "find_next(\"" << needle << "\") returned " << found_range;
109 if (found_range.is_valid()) {
110 m_editor->set_selection(found_range);
111 } else {
112 GUI::MessageBox::show(
113 String::format("Not found: \"%s\"", needle.characters()),
114 "Not found",
115 GUI::MessageBox::Type::Information,
116 GUI::MessageBox::InputType::OK, window());
117 }
118 });
119
120 m_find_previous_action = GUI::Action::create("Find previous", { Mod_Ctrl | Mod_Shift, Key_G }, [&](auto&) {
121 auto needle = m_find_textbox->text();
122 if (needle.is_empty()) {
123 dbg() << "find_prev(\"\")";
124 return;
125 }
126
127 auto selection_start = m_editor->normalized_selection().start();
128 if (!selection_start.is_valid())
129 selection_start = m_editor->normalized_selection().end();
130
131 auto found_range = m_editor->document().find_previous(needle, selection_start);
132
133 dbg() << "find_prev(\"" << needle << "\") returned " << found_range;
134 if (found_range.is_valid()) {
135 m_editor->set_selection(found_range);
136 } else {
137 GUI::MessageBox::show(
138 String::format("Not found: \"%s\"", needle.characters()),
139 "Not found",
140 GUI::MessageBox::Type::Information,
141 GUI::MessageBox::InputType::OK, window());
142 }
143 });
144
145 m_replace_next_action = GUI::Action::create("Replace next", { Mod_Ctrl, Key_F1 }, [&](auto&) {
146 auto needle = m_find_textbox->text();
147 auto substitute = m_replace_textbox->text();
148
149 if (needle.is_empty())
150 return;
151
152 auto selection_start = m_editor->normalized_selection().start();
153 if (!selection_start.is_valid())
154 selection_start = m_editor->normalized_selection().start();
155
156 auto found_range = m_editor->document().find_next(needle, selection_start);
157
158 if (found_range.is_valid()) {
159 m_editor->set_selection(found_range);
160 m_editor->insert_at_cursor_or_replace_selection(substitute);
161 } else {
162 GUI::MessageBox::show(
163 String::format("Not found: \"%s\"", needle.characters()),
164 "Not found",
165 GUI::MessageBox::Type::Information,
166 GUI::MessageBox::InputType::OK, window());
167 }
168 });
169
170 m_replace_previous_action = GUI::Action::create("Replace previous", { Mod_Ctrl | Mod_Shift, Key_F1 }, [&](auto&) {
171 auto needle = m_find_textbox->text();
172 auto substitute = m_replace_textbox->text();
173 if (needle.is_empty())
174 return;
175
176 auto selection_start = m_editor->normalized_selection().start();
177 if (!selection_start.is_valid())
178 selection_start = m_editor->normalized_selection().start();
179
180 auto found_range = m_editor->document().find_previous(needle, selection_start);
181
182 if (found_range.is_valid()) {
183 m_editor->set_selection(found_range);
184 m_editor->insert_at_cursor_or_replace_selection(substitute);
185 } else {
186 GUI::MessageBox::show(
187 String::format("Not found: \"%s\"", needle.characters()),
188 "Not found",
189 GUI::MessageBox::Type::Information,
190 GUI::MessageBox::InputType::OK, window());
191 }
192 });
193
194 m_replace_all_action = GUI::Action::create("Replace all", { Mod_Ctrl, Key_F2 }, [&](auto&) {
195 auto needle = m_find_textbox->text();
196 auto substitute = m_replace_textbox->text();
197 if (needle.is_empty())
198 return;
199
200 auto found_range = m_editor->document().find_next(needle);
201 while (found_range.is_valid()) {
202 m_editor->set_selection(found_range);
203 m_editor->insert_at_cursor_or_replace_selection(substitute);
204 found_range = m_editor->document().find_next(needle);
205 }
206 });
207
208 m_find_previous_button = m_find_widget->add<GUI::Button>("Find previous");
209 m_find_previous_button->set_size_policy(GUI::SizePolicy::Fixed, GUI::SizePolicy::Fill);
210 m_find_previous_button->set_preferred_size(150, 0);
211 m_find_previous_button->set_action(*m_find_previous_action);
212
213 m_find_next_button = m_find_widget->add<GUI::Button>("Find next");
214 m_find_next_button->set_size_policy(GUI::SizePolicy::Fixed, GUI::SizePolicy::Fill);
215 m_find_next_button->set_preferred_size(150, 0);
216 m_find_next_button->set_action(*m_find_next_action);
217
218 m_find_textbox->on_return_pressed = [this] {
219 m_find_next_button->click();
220 };
221
222 m_find_textbox->on_escape_pressed = [this] {
223 m_find_replace_widget->set_visible(false);
224 m_editor->set_focus(true);
225 };
226
227 m_replace_previous_button = m_replace_widget->add<GUI::Button>("Replace previous");
228 m_replace_previous_button->set_size_policy(GUI::SizePolicy::Fixed, GUI::SizePolicy::Fill);
229 m_replace_previous_button->set_preferred_size(100, 0);
230 m_replace_previous_button->set_action(*m_replace_previous_action);
231
232 m_replace_next_button = m_replace_widget->add<GUI::Button>("Replace next");
233 m_replace_next_button->set_size_policy(GUI::SizePolicy::Fixed, GUI::SizePolicy::Fill);
234 m_replace_next_button->set_preferred_size(100, 0);
235 m_replace_next_button->set_action(*m_replace_next_action);
236
237 m_replace_all_button = m_replace_widget->add<GUI::Button>("Replace all");
238 m_replace_all_button->set_size_policy(GUI::SizePolicy::Fixed, GUI::SizePolicy::Fill);
239 m_replace_all_button->set_preferred_size(100, 0);
240 m_replace_all_button->set_action(*m_replace_all_action);
241
242 m_replace_textbox->on_return_pressed = [this] {
243 m_replace_next_button->click();
244 };
245
246 m_replace_textbox->on_escape_pressed = [this] {
247 m_find_replace_widget->set_visible(false);
248 m_editor->set_focus(true);
249 };
250
251 m_find_replace_action = GUI::Action::create("Find/Replace...", { Mod_Ctrl, Key_F }, Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"), [this](auto&) {
252 m_find_replace_widget->set_visible(true);
253 m_find_widget->set_visible(true);
254 m_replace_widget->set_visible(true);
255 m_find_textbox->set_focus(true);
256
257 if (m_editor->has_selection()) {
258 auto selected_text = m_editor->document().text_in_range(m_editor->normalized_selection());
259 m_find_textbox->set_text(selected_text);
260 }
261 m_find_textbox->select_all();
262 });
263
264 m_editor->add_custom_context_menu_action(*m_find_replace_action);
265 m_editor->add_custom_context_menu_action(*m_find_next_action);
266 m_editor->add_custom_context_menu_action(*m_find_previous_action);
267
268 m_statusbar = add<GUI::StatusBar>();
269
270 m_editor->on_cursor_change = [this] {
271 StringBuilder builder;
272 builder.appendf("Line: %d, Column: %d", m_editor->cursor().line() + 1, m_editor->cursor().column());
273 m_statusbar->set_text(builder.to_string());
274 };
275
276 m_new_action = GUI::Action::create("New", { Mod_Ctrl, Key_N }, Gfx::Bitmap::load_from_file("/res/icons/16x16/new.png"), [this](const GUI::Action&) {
277 if (m_document_dirty) {
278 auto save_document_first_result = GUI::MessageBox::show("Save Document First?", "Warning", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel);
279 if (save_document_first_result == GUI::Dialog::ExecResult::ExecYes)
280 m_save_action->activate();
281 if (save_document_first_result == GUI::Dialog::ExecResult::ExecCancel)
282 return;
283 }
284
285 m_document_dirty = false;
286 m_editor->set_text(StringView());
287 set_path(FileSystemPath());
288 update_title();
289 });
290
291 m_open_action = GUI::CommonActions::make_open_action([this](auto&) {
292 Optional<String> open_path = GUI::FilePicker::get_open_filepath();
293
294 if (!open_path.has_value())
295 return;
296
297 if (m_document_dirty) {
298 auto save_document_first_result = GUI::MessageBox::show("Save Document First?", "Warning", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel, window());
299 if (save_document_first_result == GUI::Dialog::ExecResult::ExecYes)
300 m_save_action->activate();
301 if (save_document_first_result == GUI::Dialog::ExecResult::ExecCancel)
302 return;
303 }
304
305 open_sesame(open_path.value());
306 });
307
308 m_save_as_action = GUI::Action::create("Save as...", { Mod_Ctrl | Mod_Shift, Key_S }, Gfx::Bitmap::load_from_file("/res/icons/16x16/save.png"), [this](const GUI::Action&) {
309 Optional<String> save_path = GUI::FilePicker::get_save_filepath(m_name.is_null() ? "Untitled" : m_name, m_extension.is_null() ? "txt" : m_extension);
310 if (!save_path.has_value())
311 return;
312
313 if (!m_editor->write_to_file(save_path.value())) {
314 GUI::MessageBox::show("Unable to save file.\n", "Error", GUI::MessageBox::Type::Error, GUI::MessageBox::InputType::OK, window());
315 return;
316 }
317
318 m_document_dirty = false;
319 set_path(FileSystemPath(save_path.value()));
320 dbg() << "Wrote document to " << save_path.value();
321 });
322
323 m_save_action = GUI::Action::create("Save", { Mod_Ctrl, Key_S }, Gfx::Bitmap::load_from_file("/res/icons/16x16/save.png"), [&](const GUI::Action&) {
324 if (!m_path.is_empty()) {
325 if (!m_editor->write_to_file(m_path)) {
326 GUI::MessageBox::show("Unable to save file.\n", "Error", GUI::MessageBox::Type::Error, GUI::MessageBox::InputType::OK, window());
327 } else {
328 m_document_dirty = false;
329 update_title();
330 }
331 return;
332 }
333
334 m_save_as_action->activate();
335 });
336
337 m_line_wrapping_setting_action = GUI::Action::create("Line wrapping", [&](GUI::Action& action) {
338 action.set_checked(!action.is_checked());
339 m_editor->set_line_wrapping_enabled(action.is_checked());
340 });
341 m_line_wrapping_setting_action->set_checkable(true);
342 m_line_wrapping_setting_action->set_checked(m_editor->is_line_wrapping_enabled());
343
344 auto menubar = make<GUI::MenuBar>();
345 auto& app_menu = menubar->add_menu("Text Editor");
346 app_menu.add_action(*m_new_action);
347 app_menu.add_action(*m_open_action);
348 app_menu.add_action(*m_save_action);
349 app_menu.add_action(*m_save_as_action);
350 app_menu.add_separator();
351 app_menu.add_action(GUI::CommonActions::make_quit_action([this](auto&) {
352 if (!request_close())
353 return;
354 GUI::Application::the().quit(0);
355 }));
356
357 auto& edit_menu = menubar->add_menu("Edit");
358 edit_menu.add_action(m_editor->undo_action());
359 edit_menu.add_action(m_editor->redo_action());
360 edit_menu.add_separator();
361 edit_menu.add_action(m_editor->cut_action());
362 edit_menu.add_action(m_editor->copy_action());
363 edit_menu.add_action(m_editor->paste_action());
364 edit_menu.add_action(m_editor->delete_action());
365 edit_menu.add_separator();
366 edit_menu.add_action(*m_find_replace_action);
367 edit_menu.add_action(*m_find_next_action);
368 edit_menu.add_action(*m_find_previous_action);
369 edit_menu.add_action(*m_replace_next_action);
370 edit_menu.add_action(*m_replace_previous_action);
371 edit_menu.add_action(*m_replace_all_action);
372
373 auto& font_menu = menubar->add_menu("Font");
374 GUI::FontDatabase::the().for_each_fixed_width_font([&](const StringView& font_name) {
375 font_menu.add_action(GUI::Action::create(font_name, [this](const GUI::Action& action) {
376 m_editor->set_font(GUI::FontDatabase::the().get_by_name(action.text()));
377 m_editor->update();
378 }));
379 });
380
381 syntax_actions = GUI::ActionGroup {};
382 syntax_actions.set_exclusive(true);
383
384 auto& syntax_menu = menubar->add_menu("Syntax");
385 m_plain_text_highlight = GUI::Action::create("Plain Text", [&](GUI::Action& action) {
386 action.set_checked(true);
387 m_editor->set_syntax_highlighter(nullptr);
388 m_editor->update();
389 });
390 m_plain_text_highlight->set_checkable(true);
391 m_plain_text_highlight->set_checked(true);
392 syntax_actions.add_action(*m_plain_text_highlight);
393 syntax_menu.add_action(*m_plain_text_highlight);
394
395 m_cpp_highlight = GUI::Action::create("C++", [&](GUI::Action& action) {
396 action.set_checked(true);
397 m_editor->set_syntax_highlighter(make<GUI::CppSyntaxHighlighter>());
398 m_editor->update();
399 });
400 m_cpp_highlight->set_checkable(true);
401 syntax_actions.add_action(*m_cpp_highlight);
402 syntax_menu.add_action(*m_cpp_highlight);
403
404 m_js_highlight = GUI::Action::create("Javascript", [&](GUI::Action& action) {
405 action.set_checked(true);
406 m_editor->set_syntax_highlighter(make<GUI::JSSyntaxHighlighter>());
407 m_editor->update();
408 });
409 m_js_highlight->set_checkable(true);
410 syntax_actions.add_action(*m_js_highlight);
411 syntax_menu.add_action(*m_js_highlight);
412
413 auto& view_menu = menubar->add_menu("View");
414 view_menu.add_action(*m_line_wrapping_setting_action);
415 view_menu.add_separator();
416 view_menu.add_submenu(move(font_menu));
417 view_menu.add_submenu(move(syntax_menu));
418
419 auto& help_menu = menubar->add_menu("Help");
420 help_menu.add_action(GUI::Action::create("About", [&](auto&) {
421 GUI::AboutDialog::show("Text Editor", Gfx::Bitmap::load_from_file("/res/icons/32x32/app-texteditor.png"), window());
422 }));
423
424 GUI::Application::the().set_menubar(move(menubar));
425
426 toolbar.add_action(*m_new_action);
427 toolbar.add_action(*m_open_action);
428 toolbar.add_action(*m_save_action);
429
430 toolbar.add_separator();
431
432 toolbar.add_action(m_editor->cut_action());
433 toolbar.add_action(m_editor->copy_action());
434 toolbar.add_action(m_editor->paste_action());
435 toolbar.add_action(m_editor->delete_action());
436
437 toolbar.add_separator();
438
439 toolbar.add_action(m_editor->undo_action());
440 toolbar.add_action(m_editor->redo_action());
441}
442
443TextEditorWidget::~TextEditorWidget()
444{
445}
446
447void TextEditorWidget::set_path(const FileSystemPath& file)
448{
449 m_path = file.string();
450 m_name = file.title();
451 m_extension = file.extension();
452
453 if (m_extension == "cpp" || m_extension == "h")
454 m_cpp_highlight->activate();
455 else if (m_extension == "js")
456 m_js_highlight->activate();
457 else
458 m_plain_text_highlight->activate();
459
460 update_title();
461}
462
463void TextEditorWidget::update_title()
464{
465 StringBuilder builder;
466 builder.append(m_path);
467 if (m_document_dirty)
468 builder.append(" (*)");
469 builder.append(" - Text Editor");
470 window()->set_title(builder.to_string());
471}
472
473void TextEditorWidget::open_sesame(const String& path)
474{
475 auto file = Core::File::construct(path);
476 if (!file->open(Core::IODevice::ReadOnly)) {
477 GUI::MessageBox::show(String::format("Opening \"%s\" failed: %s", path.characters(), strerror(errno)), "Error", GUI::MessageBox::Type::Error, GUI::MessageBox::InputType::OK, window());
478 return;
479 }
480
481 m_editor->set_text(file->read_all());
482 m_document_dirty = false;
483 m_document_opening = true;
484
485 set_path(FileSystemPath(path));
486
487 m_editor->set_focus(true);
488}
489
490bool TextEditorWidget::request_close()
491{
492 if (!m_document_dirty)
493 return true;
494 auto result = GUI::MessageBox::show("The document has been modified. Would you like to save?", "Unsaved changes", GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::YesNoCancel, window());
495
496 if (result == GUI::MessageBox::ExecYes) {
497 m_save_action->activate();
498 return true;
499 }
500
501 if (result == GUI::MessageBox::ExecNo)
502 return true;
503
504 return false;
505}
506
507void TextEditorWidget::drop_event(GUI::DropEvent& event)
508{
509 event.accept();
510 window()->move_to_front();
511
512 if (event.mime_data().has_urls()) {
513 auto urls = event.mime_data().urls();
514 if (urls.is_empty())
515 return;
516 if (urls.size() > 1) {
517 GUI::MessageBox::show("TextEditor can only open one file at a time!", "One at a time please!", GUI::MessageBox::Type::Error, GUI::MessageBox::InputType::OK, window());
518 return;
519 }
520 open_sesame(urls.first().path());
521 }
522}