diff --git a/Base/usr/share/man/man1/Applications/Terminal.md b/Base/usr/share/man/man1/Applications/Terminal.md index c3e720d0bc49c9..b96bb81f004083 100644 --- a/Base/usr/share/man/man1/Applications/Terminal.md +++ b/Base/usr/share/man/man1/Applications/Terminal.md @@ -12,23 +12,31 @@ $ Terminal [options] ## Description -Terminal is a terminal emulator application for Serenity. +Terminal is a terminal emulator application for Serenity. It supports multiple tabs within a single window. It can be launched from the System Menu or the quick access icon to its right, via the `Open in Terminal` action in File Manager and on the Desktop. You can also click on the `Open` link above to launch Terminal. +### Tabs + +- Open a new tab with `Ctrl+T` or via `File → New Tab`, or by clicking the `+` button in the tab bar. +- Close a tab by clicking the `×` button on it or via `File → Quit` when only one tab is open. +- The tab bar is hidden when only a single tab is open and shown automatically when more tabs are added. + +### Settings + Select `File → Terminal Settings` to launch the Terminal Settings dialog and display user configurable application properties. This dialog box contains two tabs: View and Terminal. The _View_ tab provides the most frequently sought options: -- Adjust the Terminal font (turn off `Use system default` to select a custom font. +- Adjust the Terminal font (turn off `Use system default` to select a custom font). - Specify background opacity, i.e. the amount to which the Terminal's background is transparent, displaying what's underneath. -- Change the shape of the cursor from Block, to Underscore or to Vertical bar. You can also opt to enable or disable cursor's blink property. -- To enable or disable the display of terminal scrollbar. +- Change the shape of the cursor from Block, to Underscore or to Vertical bar. You can also opt to enable or disable the cursor's blink property. +- To enable or disable the display of the terminal scrollbar. The _Terminal_ tab gives less frequently used options: - To either enable System beep, or use Visual bell or disable bell mode altogether. -- To change Terminal's exit behavior +- To change Terminal's exit behavior. Clicking on the _Apply_ button will cause the currently selected options to take effect immediately. @@ -38,8 +46,8 @@ You can toggle Fullscreen mode by pressing F11. - `--help`: Display help message and exit - `--version`: Print version -- `-e`: Execute this command inside the terminal -- `-k`: Keep the terminal open after the command has finished executing +- `-e `: Execute this command inside the terminal +- `-k`: Keep the terminal open after the command has finished executing (only valid with `-e`) ## Examples diff --git a/Userland/Applications/Browser/BrowserWindow.cpp b/Userland/Applications/Browser/BrowserWindow.cpp index 4f4408316c2406..ae942085edfec2 100644 --- a/Userland/Applications/Browser/BrowserWindow.cpp +++ b/Userland/Applications/Browser/BrowserWindow.cpp @@ -81,11 +81,6 @@ BrowserWindow::BrowserWindow(WebView::CookieJar& cookie_jar, Vector co update_displayed_zoom_level(); }; - m_tab_widget->on_middle_click = [](auto& clicked_widget) { - auto& tab = static_cast(clicked_widget); - tab.on_tab_close_request(tab); - }; - m_tab_widget->on_tab_close_click = [](auto& clicked_widget) { auto& tab = static_cast(clicked_widget); tab.on_tab_close_request(tab); @@ -96,6 +91,11 @@ BrowserWindow::BrowserWindow(WebView::CookieJar& cookie_jar, Vector co tab.context_menu_requested(context_menu_event.screen_position()); }; + m_tab_widget->set_add_tab_button_enabled(true); + m_tab_widget->on_add_tab_button_click = [this] { + create_new_tab(Browser::g_new_tab_url, Web::HTML::ActivateTab::Yes); + }; + m_window_actions.on_create_new_tab = [this] { create_new_tab(Browser::g_new_tab_url, Web::HTML::ActivateTab::Yes); }; diff --git a/Userland/Applications/PixelPaint/MainWidget.cpp b/Userland/Applications/PixelPaint/MainWidget.cpp index fc949330567165..60d97feb19f55a 100644 --- a/Userland/Applications/PixelPaint/MainWidget.cpp +++ b/Userland/Applications/PixelPaint/MainWidget.cpp @@ -70,10 +70,6 @@ MainWidget::MainWidget() } }; - m_tab_widget->on_middle_click = [&](auto& widget) { - m_tab_widget->on_tab_close_click(widget); - }; - m_tab_widget->on_tab_close_click = [&](auto& widget) { auto& image_editor = verify_cast(widget); if (image_editor.request_close()) { @@ -188,6 +184,11 @@ ErrorOr MainWidget::initialize_menubar(GUI::Window& window) } }); + m_tab_widget->set_add_tab_button_enabled(true); + m_tab_widget->on_add_tab_button_click = [this, &window] { + m_new_image_action->activate(&window); + }; + m_new_image_from_clipboard_action = GUI::Action::create( "&New Image from Clipboard", { Mod_Ctrl | Mod_Shift, Key_V }, g_icon_bag.new_clipboard, [&](auto&) { auto result = create_image_from_clipboard(); diff --git a/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp b/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp index 8462dd857b6c0e..e696f28267e2b2 100644 --- a/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp +++ b/Userland/Applications/Spreadsheet/SpreadsheetWidget.cpp @@ -119,6 +119,9 @@ SpreadsheetWidget::SpreadsheetWidget(GUI::Window& parent_window, Vectorsheets()); + m_tab_widget->set_add_tab_button_enabled(true); + m_tab_widget->on_add_tab_button_click = [&] { add_sheet(); }; + m_new_action = GUI::Action::create("Add New Sheet", Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"sv).release_value_but_fixme_should_propagate_errors(), [&](auto&) { add_sheet(); }); @@ -342,8 +345,9 @@ void SpreadsheetWidget::clipboard_content_did_change(ByteString const& mime_type m_paste_action->set_enabled(!sheet->selected_cells().is_empty() && mime_type.starts_with("text/"sv)); } -void SpreadsheetWidget::setup_tabs(Vector> new_sheets) +GUI::Widget* SpreadsheetWidget::setup_tabs(Vector> new_sheets) { + GUI::Widget* last_view = nullptr; for (auto& sheet : new_sheets) { auto& new_view = m_tab_widget->add_tab(String::from_byte_string(sheet->name()).release_value_but_fixme_should_propagate_errors(), sheet); new_view.model()->on_cell_data_change = [&](auto& cell, auto& previous_data) { @@ -439,7 +443,9 @@ void SpreadsheetWidget::setup_tabs(Vector> new_sheets) static_cast(const_cast(m_cell_value_editor->syntax_highlighter()))->set_cell(nullptr); }; + last_view = &new_view; } + return last_view; } void SpreadsheetWidget::try_generate_tip_for_input_expression(StringView source, size_t cursor_offset) @@ -644,7 +650,8 @@ void SpreadsheetWidget::add_sheet() Vector> new_sheets; new_sheets.append(m_workbook->add_sheet(name.string_view())); - setup_tabs(move(new_sheets)); + if (auto* new_tab = setup_tabs(move(new_sheets))) + m_tab_widget->set_active_widget(new_tab); } void SpreadsheetWidget::add_sheet(NonnullRefPtr&& sheet) diff --git a/Userland/Applications/Spreadsheet/SpreadsheetWidget.h b/Userland/Applications/Spreadsheet/SpreadsheetWidget.h index bdffab1411742f..2e97c9483c953d 100644 --- a/Userland/Applications/Spreadsheet/SpreadsheetWidget.h +++ b/Userland/Applications/Spreadsheet/SpreadsheetWidget.h @@ -61,7 +61,7 @@ class SpreadsheetWidget final explicit SpreadsheetWidget(GUI::Window& window, Vector>&& sheets = {}, bool should_add_sheet_if_empty = true); - void setup_tabs(Vector> new_sheets); + GUI::Widget* setup_tabs(Vector> new_sheets); void try_generate_tip_for_input_expression(StringView source, size_t offset); diff --git a/Userland/Applications/Terminal/CMakeLists.txt b/Userland/Applications/Terminal/CMakeLists.txt index f6884b73ca1473..db4cf6b37e9678 100644 --- a/Userland/Applications/Terminal/CMakeLists.txt +++ b/Userland/Applications/Terminal/CMakeLists.txt @@ -6,6 +6,7 @@ serenity_component( set(SOURCES main.cpp + TerminalWindow.cpp ) serenity_app(Terminal ICON app-terminal) diff --git a/Userland/Applications/Terminal/TerminalWindow.cpp b/Userland/Applications/Terminal/TerminalWindow.cpp new file mode 100644 index 00000000000000..43f013c1d46ba2 --- /dev/null +++ b/Userland/Applications/Terminal/TerminalWindow.cpp @@ -0,0 +1,572 @@ +/* + * Copyright (c) 2018-2021, Andreas Kling + * Copyright (c) 2026, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#include "TerminalWindow.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static ErrorOr utmp_update(StringView tty, pid_t pid, bool create) +{ + auto pid_string = String::number(pid); + Array utmp_update_command { + "-f"sv, + "Terminal"sv, + "-p"sv, + pid_string.bytes_as_string_view(), + (create ? "-c"sv : "-d"sv), + tty, + }; + + auto utmpupdate_pid = TRY(Core::Process::spawn("/bin/utmpupdate"sv, utmp_update_command, {}, Core::Process::KeepAsChild::Yes)); + + Core::System::WaitPidResult status; + auto wait_successful = false; + while (!wait_successful) { + auto result = Core::System::waitpid(utmpupdate_pid, 0); + if (result.is_error() && result.error().code() != EINTR) { + return result.release_error(); + } else if (!result.is_error()) { + status = result.release_value(); + wait_successful = true; + } + } + + if (WIFEXITED(status.status) && WEXITSTATUS(status.status) != 0) + dbgln("Terminal: utmpupdate exited with status {}", WEXITSTATUS(status.status)); + else if (WIFSIGNALED(status.status)) + dbgln("Terminal: utmpupdate exited due to unhandled signal {}", WTERMSIG(status.status)); + + return {}; +} + +static ErrorOr run_command(StringView command, bool keep_open) +{ + auto shell = TRY(String::from_byte_string(TRY(Core::Account::self(Core::Account::Read::PasswdOnly)).shell())); + if (shell.is_empty()) + shell = "/bin/Shell"_string; + + Vector arguments; + arguments.append(shell); + if (!command.is_empty()) { + if (keep_open) + arguments.append("--keep-open"sv); + arguments.append("-c"sv); + arguments.append(command); + } + auto env = TRY(FixedArray::create({ "TERM=xterm"sv, "PAGER=more"sv, "PATH="sv DEFAULT_PATH_SV })); + TRY(Core::System::exec(shell, arguments, Core::System::SearchInPath::No, env.span())); + VERIFY_NOT_REACHED(); +} + +static bool tty_has_foreground_process(int ptm_fd, pid_t shell_pid) +{ + pid_t fg_pid = tcgetpgrp(ptm_fd); + return fg_pid != -1 && fg_pid != shell_pid; +} + +static int shell_child_process_count(pid_t shell_pid) +{ + int count = 0; + Core::Directory::for_each_entry(String::formatted("/proc/{}/children", shell_pid).release_value_but_fixme_should_propagate_errors(), + Core::DirIterator::Flags::SkipParentAndBaseDir, [&](auto&, auto&) { + ++count; + return IterationDecision::Continue; + }) + .release_value_but_fixme_should_propagate_errors(); + return count; +} + +static void kill_shell_processes(pid_t shell_pid) +{ + // Kill the shell's process group (PGID == shell_pid after forkpty) and the shell itself. + (void)Core::System::kill(-shell_pid, SIGHUP); + (void)Core::System::kill(shell_pid, SIGHUP); + // Kill background children that may have their own process groups. + (void)Core::Directory::for_each_entry( + String::formatted("/proc/{}/children", shell_pid).release_value_but_fixme_should_propagate_errors(), + Core::DirIterator::Flags::SkipParentAndBaseDir, + [&](auto& entry, auto&) { + if (auto child_pid = entry.name.template to_number(); child_pid.has_value()) { + (void)Core::System::kill(-child_pid.value(), SIGHUP); + (void)Core::System::kill(child_pid.value(), SIGHUP); + } + return IterationDecision::Continue; + }); +} + +static ErrorOr> create_find_window(VT::TerminalWidget& terminal) +{ + auto window = GUI::Window::construct(&terminal); + window->set_window_mode(GUI::WindowMode::RenderAbove); + window->set_title("Find in Terminal"); + window->set_resizable(false); + window->resize(300, 90); + + auto main_widget = window->set_main_widget(); + main_widget->set_fill_with_background_color(true); + main_widget->set_background_role(Gfx::ColorRole::Button); + main_widget->set_layout(4); + + auto& find = main_widget->add(); + find.set_layout(4); + find.set_fixed_height(30); + + auto& find_textbox = find.add(); + find_textbox.set_fixed_width(230); + find_textbox.set_focus(true); + if (terminal.has_selection()) + find_textbox.set_text(terminal.selected_text().replace("\n"sv, " "sv, ReplaceMode::All)); + auto& find_backwards = find.add(); + find_backwards.set_fixed_width(25); + find_backwards.set_icon(TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/upward-triangle.png"sv))); + auto& find_forwards = find.add(); + find_forwards.set_fixed_width(25); + find_forwards.set_icon(TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/downward-triangle.png"sv))); + + find_textbox.on_return_pressed = [&find_backwards] { find_backwards.click(); }; + find_textbox.on_shift_return_pressed = [&find_forwards] { find_forwards.click(); }; + + auto& match_case = main_widget->add("Case sensitive"_string); + auto& wrap_around = main_widget->add("Wrap around"_string); + + find_backwards.on_click = [&terminal, &find_textbox, &match_case, &wrap_around](auto) { + auto needle = find_textbox.text(); + if (needle.is_empty()) + return; + auto found_range = terminal.find_previous(needle, terminal.normalized_selection().start(), match_case.is_checked(), wrap_around.is_checked()); + if (found_range.is_valid()) { + terminal.scroll_to_row(found_range.start().row()); + terminal.set_selection(found_range); + } + }; + find_forwards.on_click = [&terminal, &find_textbox, &match_case, &wrap_around](auto) { + auto needle = find_textbox.text(); + if (needle.is_empty()) + return; + auto found_range = terminal.find_next(needle, terminal.normalized_selection().end(), match_case.is_checked(), wrap_around.is_checked()); + if (found_range.is_valid()) { + terminal.scroll_to_row(found_range.start().row()); + terminal.set_selection(found_range); + } + }; + + return window; +} + +TerminalWindow::TerminalWindow() +{ + auto app_icon = GUI::Icon::default_icon("app-terminal"sv); + set_title("Terminal"); + set_obey_widget_min_size(false); + set_icon(app_icon.bitmap_for_size(16)); + + Config::monitor_domain("Terminal"); + m_should_confirm_close = Config::read_bool("Terminal"sv, "Terminal"sv, "ConfirmClose"sv, true); + m_bell = VT::TerminalWidget::parse_bell(Config::read_string("Terminal"sv, "Window"sv, "Bell"sv, "Visible"sv)).value_or(VT::TerminalWidget::BellMode::Visible); + m_automark = VT::TerminalWidget::parse_automark_mode(Config::read_string("Terminal"sv, "Terminal"sv, "AutoMark"sv, "MarkInteractiveShellPrompt"sv)).value_or(VT::TerminalWidget::AutoMarkMode::MarkInteractiveShellPrompt); + m_cursor_shape = VT::TerminalWidget::parse_cursor_shape(Config::read_string("Terminal"sv, "Cursor"sv, "Shape"sv, "Block"sv)).value_or(VT::CursorShape::Block); + m_cursor_blinking = Config::read_bool("Terminal"sv, "Cursor"sv, "Blinking"sv, true); + m_opacity = Config::read_i32("Terminal"sv, "Window"sv, "Opacity"sv, 255); + m_show_scroll_bar = Config::read_bool("Terminal"sv, "Terminal"sv, "ShowScrollBar"sv, true); + set_has_alpha_channel(m_opacity < 255); + + auto main_widget = set_main_widget(); + main_widget->set_layout(0); + main_widget->set_fill_with_background_color(true); + + m_top_line = main_widget->add(); + m_top_line->set_fixed_height(2); + m_top_line->set_visible(false); + + m_tab_widget = main_widget->add(); + m_tab_widget->set_container_margins(GUI::Margins { 0 }); + m_tab_widget->set_reorder_allowed(true); + m_tab_widget->set_close_button_enabled(true); + m_tab_widget->on_tab_count_change = [this](size_t count) { + m_top_line->set_visible(count > 1); + m_tab_widget->set_bar_visible(!is_fullscreen() && count > 1); + if (count == 0) + GUI::Application::the()->quit(0); + }; + m_tab_widget->on_change = [this](auto& widget) { + auto& terminal = verify_cast(widget); + terminal.apply_size_increments_to_window(*this); + for (auto& data : m_tab_data_list) { + if (data.terminal.ptr() == &terminal) { + set_title(data.title.is_empty() ? "Terminal" : data.title); + break; + } + } + }; + m_tab_widget->on_tab_close_click = [this](auto& widget) { + auto& terminal = verify_cast(widget); + if (check_tab_quit(terminal) == GUI::MessageBox::ExecResult::OK) { + close_terminal_tab(terminal); + } + }; + m_tab_widget->set_add_tab_button_enabled(true); + m_tab_widget->on_add_tab_button_click = [this] { (void)open_new_terminal_tab(); }; + + on_close_request = [this]() -> GUI::Window::CloseRequestDecision { + if (check_terminal_quit() == GUI::MessageBox::ExecResult::OK) + return GUI::Window::CloseRequestDecision::Close; + return GUI::Window::CloseRequestDecision::StayOpen; + }; + + on_input_preemption_change = [this](bool is_preempted) { + active_terminal().set_logical_focus(!is_preempted); + }; + + m_modified_state_check_timer = Core::Timer::create_repeating(500, [this] { + for (auto& data : m_tab_data_list) { + bool modified = tty_has_foreground_process(data.ptm_fd, data.shell_pid) + || shell_child_process_count(data.shell_pid) > 0; + m_tab_widget->set_tab_modified(*data.terminal, modified); + } + set_modified(m_tab_widget->is_any_tab_modified()); + }); +} + +ErrorOr TerminalWindow::initialize(StringView initial_command, bool keep_open) +{ + auto app_icon = GUI::Icon::default_icon("app-terminal"sv); + m_open_settings_action = GUI::Action::create("Terminal &Settings", + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/settings.png"sv)), + [this](auto&) { GUI::Process::spawn_or_show_error(this, "/bin/TerminalSettings"sv); }); + TRY(build_menus(app_icon)); + + TRY(open_new_terminal_tab(initial_command, keep_open)); + + Optional fallback_size; + auto increment = size_increment(); + if (increment.width() > 0 && increment.height() > 0) { + auto base = base_size(); + fallback_size = Gfx::IntSize { base.width() + 80 * increment.width(), base.height() + 25 * increment.height() }; + } + restore_size_and_position("Terminal"sv, "Geometry"sv, fallback_size); + save_size_and_position_on_close("Terminal"sv, "Geometry"sv); + + if (m_should_confirm_close) + m_modified_state_check_timer->start(); + + return {}; +} + +ErrorOr TerminalWindow::build_menus(GUI::Icon const& app_icon) +{ + auto file_menu = add_menu("&File"_string); + file_menu->add_action(GUI::Action::create("New &Tab", { Mod_Ctrl | Mod_Shift, Key_T }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/new-tab.png"sv)), + [this](auto&) { (void)open_new_terminal_tab(); })); + file_menu->add_action(GUI::Action::create("Open New &Window", { Mod_Ctrl | Mod_Shift, Key_N }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-terminal.png"sv)), + [this](auto&) { GUI::Process::spawn_or_show_error(this, "/bin/Terminal"sv); })); + file_menu->add_action(*m_open_settings_action); + file_menu->add_separator(); + file_menu->add_action(GUI::CommonActions::make_quit_action( + [this](auto&) { + dbgln("Terminal: Quit menu activated!"); + if (check_terminal_quit() == GUI::MessageBox::ExecResult::OK) + GUI::Application::the()->quit(); + }, + GUI::CommonActions::QuitAltShortcut::None)); + + auto edit_menu = add_menu("&Edit"_string); + edit_menu->add_action(GUI::Action::create("&Copy", { Mod_Ctrl | Mod_Shift, Key_C }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/edit-copy.png"sv)), + [this](auto&) { active_terminal().copy(); })); + edit_menu->add_action(GUI::Action::create("&Paste", { Mod_Ctrl | Mod_Shift, Key_V }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/paste.png"sv)), + [this](auto&) { active_terminal().paste(); })); + edit_menu->add_separator(); + edit_menu->add_action(GUI::Action::create("&Find...", { Mod_Ctrl | Mod_Shift, Key_F }, + TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"sv)), + [this](auto&) { + auto& terminal = active_terminal(); + for (auto& data : m_tab_data_list) { + if (data.terminal.ptr() == &terminal) { + if (!data.find_window) + data.find_window = create_find_window(terminal).release_value_but_fixme_should_propagate_errors(); + data.find_window->show(); + data.find_window->move_to_front(); + return; + } + } + })); + + auto view_menu = add_menu("&View"_string); + view_menu->add_action(GUI::CommonActions::make_fullscreen_action([this](auto&) { + set_fullscreen(!is_fullscreen()); + })); + view_menu->add_action(GUI::Action::create("Clear Including &History", { Mod_Ctrl | Mod_Shift, Key_K }, + [this](auto&) { active_terminal().clear_including_history(); })); + view_menu->add_action(GUI::Action::create("Clear &Previous Command", { Mod_Ctrl | Mod_Shift, Key_U }, + [this](auto&) { active_terminal().clear_to_previous_mark(); })); + view_menu->add_separator(); + view_menu->add_action(GUI::CommonActions::make_zoom_in_action([this](auto&) { + adjust_font_size(1, Gfx::Font::AllowInexactSizeMatch::Larger); + })); + view_menu->add_action(GUI::CommonActions::make_zoom_out_action([this](auto&) { + adjust_font_size(-1, Gfx::Font::AllowInexactSizeMatch::Smaller); + })); + + auto help_menu = add_menu("&Help"_string); + help_menu->add_action(GUI::CommonActions::make_command_palette_action(this)); + help_menu->add_action(GUI::CommonActions::make_help_action([](auto&) { + Desktop::Launcher::open(URL::create_with_file_scheme("/usr/share/man/man1/Applications/Terminal.md"), "/bin/Help"); + })); + help_menu->add_action(GUI::CommonActions::make_about_action("Terminal"_string, app_icon, this)); + return {}; +} + +VT::TerminalWidget& TerminalWindow::active_terminal() +{ + return verify_cast(*m_tab_widget->active_widget()); +} + +ErrorOr TerminalWindow::create_terminal_tab(int ptm_fd, pid_t shell_pid, ByteString ptsname) +{ + auto& terminal = m_tab_widget->add_tab("Terminal"_string, ptm_fd, false); + terminal.set_startup_process_id(shell_pid); + + terminal.set_max_history_size(Config::read_i32("Terminal"sv, "Terminal"sv, "MaxHistorySize"sv, terminal.max_history_size())); + terminal.set_show_scrollbar(m_show_scroll_bar); + terminal.set_opacity(m_opacity); + terminal.set_cursor_shape(m_cursor_shape); + terminal.set_cursor_blinking(m_cursor_blinking); + terminal.set_bell_mode(m_bell); + terminal.set_auto_mark_mode(m_automark); + terminal.apply_size_increments_to_window(*this); + terminal.on_terminal_size_change = [this](auto size) { + resize(size); + }; + terminal.context_menu().add_separator(); + terminal.context_menu().add_action(*m_open_settings_action); + + m_tab_data_list.append({ ptm_fd, shell_pid, move(ptsname), {}, terminal, nullptr }); + + auto* terminal_ptr = &terminal; + + terminal.on_command_exit = [this, terminal_ptr] { + m_tab_widget->deferred_invoke([this, terminal_ptr] { + close_terminal_tab(*terminal_ptr); + }); + }; + + terminal.on_title_change = [this, terminal_ptr](auto title) { + m_tab_widget->set_tab_title(*terminal_ptr, String::from_utf8(title).release_value_but_fixme_should_propagate_errors()); + for (auto& data : m_tab_data_list) { + if (data.terminal.ptr() == terminal_ptr) { + data.title = title; + break; + } + } + if (m_tab_widget->active_widget() == terminal_ptr) + set_title(title); + }; + + m_tab_widget->set_active_widget(&terminal); + return {}; +} + +void TerminalWindow::close_terminal_tab(VT::TerminalWidget& terminal) +{ + for (size_t i = 0; i < m_tab_data_list.size(); ++i) { + if (m_tab_data_list[i].terminal == &terminal) { + kill_shell_processes(m_tab_data_list[i].shell_pid); + (void)utmp_update(m_tab_data_list[i].ptsname, 0, false); + m_tab_data_list.remove(i); + break; + } + } + m_tab_widget->remove_tab(terminal); +} + +ErrorOr TerminalWindow::open_new_terminal_tab(StringView cmd, bool keep_open) +{ + int ptm_fd; + pid_t shell_pid = forkpty(&ptm_fd, nullptr, nullptr, nullptr); + if (shell_pid < 0) + return Error::from_errno(errno); + if (shell_pid == 0) { + auto startup_command = Config::read_string("Terminal"sv, "Startup"sv, "Command"sv, ""sv); + if (run_command(cmd.is_empty() ? startup_command.view() : cmd, keep_open).is_error()) + _exit(1); + VERIFY_NOT_REACHED(); + } + auto ptsname = TRY(Core::System::ptsname(ptm_fd)); + TRY(utmp_update(ptsname, shell_pid, true)); + return create_terminal_tab(ptm_fd, shell_pid, ptsname); +} + +ErrorOr TerminalWindow::cleanup() +{ + dbgln("Terminal: Exiting, updating utmp"); + for (auto& data : m_tab_data_list) { + kill_shell_processes(data.shell_pid); + TRY(utmp_update(data.ptsname, 0, false)); + } + return {}; +} + +GUI::Dialog::ExecResult TerminalWindow::check_terminal_quit() +{ + if (!m_should_confirm_close) + return GUI::MessageBox::ExecResult::OK; + bool has_foreground = false; + int total_children = 0; + for (auto& data : m_tab_data_list) { + if (tty_has_foreground_process(data.ptm_fd, data.shell_pid)) + has_foreground = true; + total_children += shell_child_process_count(data.shell_pid); + } + Optional close_message; + auto title = "Running Process"sv; + if (has_foreground) { + close_message = "Close Terminal and kill its foreground process?"_string; + } else { + if (total_children > 1) { + title = "Running Processes"sv; + close_message = String::formatted("Close Terminal and kill its {} background processes?", total_children).release_value_but_fixme_should_propagate_errors(); + } else if (total_children == 1) { + close_message = "Close Terminal and kill its background process?"_string; + } + } + if (close_message.has_value()) + return GUI::MessageBox::show(this, *close_message, title, GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::OKCancel); + return GUI::MessageBox::ExecResult::OK; +} + +GUI::Dialog::ExecResult TerminalWindow::check_tab_quit(VT::TerminalWidget& terminal) +{ + if (!m_should_confirm_close) + return GUI::MessageBox::ExecResult::OK; + for (auto& data : m_tab_data_list) { + if (data.terminal.ptr() != &terminal) + continue; + bool has_foreground = tty_has_foreground_process(data.ptm_fd, data.shell_pid); + int children = shell_child_process_count(data.shell_pid); + Optional close_message; + auto title = "Running Process"sv; + if (has_foreground) { + close_message = "Close tab and kill its foreground process?"_string; + } else if (children > 1) { + title = "Running Processes"sv; + close_message = String::formatted("Close tab and kill its {} background processes?", children).release_value_but_fixme_should_propagate_errors(); + } else if (children == 1) { + close_message = "Close tab and kill its background process?"_string; + } + if (close_message.has_value()) + return GUI::MessageBox::show(this, *close_message, title, GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::OKCancel); + return GUI::MessageBox::ExecResult::OK; + } + return GUI::MessageBox::ExecResult::OK; +} + +void TerminalWindow::adjust_font_size(float adjustment, Gfx::Font::AllowInexactSizeMatch preference) +{ + auto& terminal = active_terminal(); + auto& font = terminal.font(); + auto new_size = max(5, font.presentation_size() + adjustment); + if (auto new_font = Gfx::FontDatabase::the().get(font.family(), new_size, font.weight(), font.width(), font.slope(), preference)) { + terminal.set_font_and_resize_to_fit(*new_font); + terminal.apply_size_increments_to_window(*this); + resize(terminal.size()); + } +} + +void TerminalWindow::config_bool_did_change(StringView domain, StringView group, StringView key, bool value) +{ + VERIFY(domain == "Terminal"); + + if (group == "Terminal") { + if (key == "ShowScrollBar") { + m_show_scroll_bar = value; + for (auto& data : m_tab_data_list) + data.terminal->set_show_scrollbar(value); + } else if (key == "ConfirmClose") { + m_should_confirm_close = value; + if (value) { + m_modified_state_check_timer->start(); + } else { + m_modified_state_check_timer->stop(); + set_modified(false); + } + } + } else if (group == "Cursor" && key == "Blinking") { + m_cursor_blinking = value; + for (auto& data : m_tab_data_list) + data.terminal->set_cursor_blinking(value); + } +} + +void TerminalWindow::config_string_did_change(StringView domain, StringView group, StringView key, StringView value) +{ + VERIFY(domain == "Terminal"); + + if (group == "Window" && key == "Bell") { + m_bell = VT::TerminalWidget::parse_bell(value).value_or(VT::TerminalWidget::BellMode::Visible); + for (auto& data : m_tab_data_list) + data.terminal->set_bell_mode(m_bell); + } else if (group == "Text" && key == "Font") { + auto font = Gfx::FontDatabase::the().get_by_name(value); + if (font.is_null()) + font = Gfx::FontDatabase::default_fixed_width_font(); + for (auto& data : m_tab_data_list) { + data.terminal->set_font_and_resize_to_fit(*font); + data.terminal->apply_size_increments_to_window(*data.terminal->window()); + data.terminal->window()->resize(data.terminal->size()); + } + } else if (group == "Cursor" && key == "Shape") { + m_cursor_shape = VT::TerminalWidget::parse_cursor_shape(value).value_or(VT::CursorShape::Block); + for (auto& data : m_tab_data_list) + data.terminal->set_cursor_shape(m_cursor_shape); + } else if (group == "Terminal" && key == "AutoMark") { + m_automark = VT::TerminalWidget::parse_automark_mode(value).value_or(VT::TerminalWidget::AutoMarkMode::MarkInteractiveShellPrompt); + for (auto& data : m_tab_data_list) + data.terminal->set_auto_mark_mode(m_automark); + } +} + +void TerminalWindow::config_i32_did_change(StringView domain, StringView group, StringView key, i32 value) +{ + VERIFY(domain == "Terminal"); + + if (group == "Terminal" && key == "MaxHistorySize") { + for (auto& data : m_tab_data_list) + data.terminal->set_max_history_size(value); + } else if (group == "Window" && key == "Opacity") { + m_opacity = value; + set_has_alpha_channel(value < 255); + for (auto& data : m_tab_data_list) + data.terminal->set_opacity(value); + } +} diff --git a/Userland/Applications/Terminal/TerminalWindow.h b/Userland/Applications/Terminal/TerminalWindow.h new file mode 100644 index 00000000000000..d5d9730dbcd0d5 --- /dev/null +++ b/Userland/Applications/Terminal/TerminalWindow.h @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2018-2021, Andreas Kling + * Copyright (c) 2026, Bastiaan van der Plaat + * + * SPDX-License-Identifier: BSD-2-Clause + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TerminalWindow final : public GUI::Window + , public Config::Listener { + C_OBJECT(TerminalWindow); + +public: + virtual ~TerminalWindow() override = default; + + ErrorOr initialize(StringView initial_command, bool keep_open); + ErrorOr cleanup(); + +private: + TerminalWindow(); + + struct TabData { + int ptm_fd; + pid_t shell_pid; + ByteString ptsname; + ByteString title; + RefPtr terminal; + RefPtr find_window; + }; + + ErrorOr build_menus(GUI::Icon const& app_icon); + VT::TerminalWidget& active_terminal(); + ErrorOr create_terminal_tab(int ptm_fd, pid_t shell_pid, ByteString ptsname); + void close_terminal_tab(VT::TerminalWidget&); + ErrorOr open_new_terminal_tab(StringView cmd = {}, bool keep_open = false); + GUI::Dialog::ExecResult check_terminal_quit(); + GUI::Dialog::ExecResult check_tab_quit(VT::TerminalWidget&); + void adjust_font_size(float adjustment, Gfx::Font::AllowInexactSizeMatch); + + // ^Config::Listener + virtual void config_bool_did_change(StringView domain, StringView group, StringView key, bool value) override; + virtual void config_string_did_change(StringView domain, StringView group, StringView key, StringView value) override; + virtual void config_i32_did_change(StringView domain, StringView group, StringView key, i32 value) override; + + RefPtr m_tab_widget; + RefPtr m_top_line; + RefPtr m_open_settings_action; + Vector m_tab_data_list; + bool m_should_confirm_close { true }; + RefPtr m_modified_state_check_timer; + + // Cached config values + VT::TerminalWidget::BellMode m_bell; + VT::TerminalWidget::AutoMarkMode m_automark; + VT::CursorShape m_cursor_shape { VT::CursorShape::Block }; + bool m_cursor_blinking { true }; + i32 m_opacity { 255 }; + bool m_show_scroll_bar { true }; +}; diff --git a/Userland/Applications/Terminal/main.cpp b/Userland/Applications/Terminal/main.cpp index e953b7df33a568..d04462b2711179 100644 --- a/Userland/Applications/Terminal/main.cpp +++ b/Userland/Applications/Terminal/main.cpp @@ -1,227 +1,18 @@ /* * Copyright (c) 2018-2021, Andreas Kling + * Copyright (c) 2026, Bastiaan van der Plaat * * SPDX-License-Identifier: BSD-2-Clause */ -#include -#include -#include +#include "TerminalWindow.h" #include -#include #include #include -#include #include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include - -class TerminalChangeListener : public Config::Listener { -public: - TerminalChangeListener(VT::TerminalWidget& parent_terminal) - : m_parent_terminal(parent_terminal) - { - } - - virtual void config_bool_did_change(StringView domain, StringView group, StringView key, bool value) override - { - VERIFY(domain == "Terminal"); - - if (group == "Terminal") { - if (key == "ShowScrollBar") - m_parent_terminal.set_show_scrollbar(value); - else if (key == "ConfirmClose" && on_confirm_close_changed) - on_confirm_close_changed(value); - } else if (group == "Cursor" && key == "Blinking") { - m_parent_terminal.set_cursor_blinking(value); - } - } - - virtual void config_string_did_change(StringView domain, StringView group, StringView key, StringView value) override - { - VERIFY(domain == "Terminal"); - - if (group == "Window" && key == "Bell") { - auto bell_mode = VT::TerminalWidget::parse_bell(value).value_or(VT::TerminalWidget::BellMode::Visible); - m_parent_terminal.set_bell_mode(bell_mode); - } else if (group == "Text" && key == "Font") { - auto font = Gfx::FontDatabase::the().get_by_name(value); - if (font.is_null()) - font = Gfx::FontDatabase::default_fixed_width_font(); - m_parent_terminal.set_font_and_resize_to_fit(*font); - m_parent_terminal.apply_size_increments_to_window(*m_parent_terminal.window()); - m_parent_terminal.window()->resize(m_parent_terminal.size()); - } else if (group == "Cursor" && key == "Shape") { - auto cursor_shape = VT::TerminalWidget::parse_cursor_shape(value).value_or(VT::CursorShape::Block); - m_parent_terminal.set_cursor_shape(cursor_shape); - } else if (group == "Terminal" && key == "AutoMark") { - auto automark_mode = VT::TerminalWidget::parse_automark_mode(value).value_or(VT::TerminalWidget::AutoMarkMode::MarkInteractiveShellPrompt); - m_parent_terminal.set_auto_mark_mode(automark_mode); - } - } - - virtual void config_i32_did_change(StringView domain, StringView group, StringView key, i32 value) override - { - VERIFY(domain == "Terminal"); - - if (group == "Terminal" && key == "MaxHistorySize") { - m_parent_terminal.set_max_history_size(value); - } else if (group == "Window" && key == "Opacity") { - m_parent_terminal.set_opacity(value); - } - } - - Function on_confirm_close_changed; - -private: - VT::TerminalWidget& m_parent_terminal; -}; - -static ErrorOr utmp_update(StringView tty, pid_t pid, bool create) -{ - auto pid_string = String::number(pid); - Array utmp_update_command { - "-f"sv, - "Terminal"sv, - "-p"sv, - pid_string.bytes_as_string_view(), - (create ? "-c"sv : "-d"sv), - tty, - }; - - auto utmpupdate_pid = TRY(Core::Process::spawn("/bin/utmpupdate"sv, utmp_update_command, {}, Core::Process::KeepAsChild::Yes)); - - Core::System::WaitPidResult status; - auto wait_successful = false; - while (!wait_successful) { - auto result = Core::System::waitpid(utmpupdate_pid, 0); - if (result.is_error() && result.error().code() != EINTR) { - return result.release_error(); - } else if (!result.is_error()) { - status = result.release_value(); - wait_successful = true; - } - } - - if (WIFEXITED(status.status) && WEXITSTATUS(status.status) != 0) - dbgln("Terminal: utmpupdate exited with status {}", WEXITSTATUS(status.status)); - else if (WIFSIGNALED(status.status)) - dbgln("Terminal: utmpupdate exited due to unhandled signal {}", WTERMSIG(status.status)); - - return {}; -} - -static ErrorOr run_command(StringView command, bool keep_open) -{ - auto shell = TRY(String::from_byte_string(TRY(Core::Account::self(Core::Account::Read::PasswdOnly)).shell())); - if (shell.is_empty()) - shell = "/bin/Shell"_string; - - Vector arguments; - arguments.append(shell); - if (!command.is_empty()) { - if (keep_open) - arguments.append("--keep-open"sv); - arguments.append("-c"sv); - arguments.append(command); - } - auto env = TRY(FixedArray::create({ "TERM=xterm"sv, "PAGER=more"sv, "PATH="sv DEFAULT_PATH_SV })); - TRY(Core::System::exec(shell, arguments, Core::System::SearchInPath::No, env.span())); - VERIFY_NOT_REACHED(); -} - -static ErrorOr> create_find_window(VT::TerminalWidget& terminal) -{ - auto window = GUI::Window::construct(&terminal); - window->set_window_mode(GUI::WindowMode::RenderAbove); - window->set_title("Find in Terminal"); - window->set_resizable(false); - window->resize(300, 90); - - auto main_widget = window->set_main_widget(); - main_widget->set_fill_with_background_color(true); - main_widget->set_background_role(ColorRole::Button); - main_widget->set_layout(4); - - auto& find = main_widget->add(); - find.set_layout(4); - find.set_fixed_height(30); - - auto& find_textbox = find.add(); - find_textbox.set_fixed_width(230); - find_textbox.set_focus(true); - if (terminal.has_selection()) - find_textbox.set_text(terminal.selected_text().replace("\n"sv, " "sv, ReplaceMode::All)); - auto& find_backwards = find.add(); - find_backwards.set_fixed_width(25); - find_backwards.set_icon(TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/upward-triangle.png"sv))); - auto& find_forwards = find.add(); - find_forwards.set_fixed_width(25); - find_forwards.set_icon(TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/downward-triangle.png"sv))); - - find_textbox.on_return_pressed = [&find_backwards] { - find_backwards.click(); - }; - - find_textbox.on_shift_return_pressed = [&find_forwards] { - find_forwards.click(); - }; - - auto& match_case = main_widget->add("Case sensitive"_string); - auto& wrap_around = main_widget->add("Wrap around"_string); - - find_backwards.on_click = [&terminal, &find_textbox, &match_case, &wrap_around](auto) { - auto needle = find_textbox.text(); - if (needle.is_empty()) { - return; - } - - auto found_range = terminal.find_previous(needle, terminal.normalized_selection().start(), match_case.is_checked(), wrap_around.is_checked()); - - if (found_range.is_valid()) { - terminal.scroll_to_row(found_range.start().row()); - terminal.set_selection(found_range); - } - }; - find_forwards.on_click = [&terminal, &find_textbox, &match_case, &wrap_around](auto) { - auto needle = find_textbox.text(); - if (needle.is_empty()) { - return; - } - - auto found_range = terminal.find_next(needle, terminal.normalized_selection().end(), match_case.is_checked(), wrap_around.is_checked()); - - if (found_range.is_valid()) { - terminal.scroll_to_row(found_range.start().row()); - terminal.set_selection(found_range); - } - }; - - return window; -} +#include ErrorOr serenity_main(Main::Arguments arguments) { @@ -231,10 +22,8 @@ ErrorOr serenity_main(Main::Arguments arguments) act.sa_mask = 0; // Do not trust that both function pointers overlap. act.sa_sigaction = nullptr; - act.sa_flags = SA_NOCLDWAIT; act.sa_handler = SIG_IGN; - TRY(Core::System::sigaction(SIGCHLD, &act, nullptr)); auto app = TRY(GUI::Application::create(arguments)); @@ -249,7 +38,6 @@ ErrorOr serenity_main(Main::Arguments arguments) Core::ArgsParser args_parser; args_parser.add_option(command_to_execute, "Execute this command inside the terminal", nullptr, 'e', "command"); args_parser.add_option(keep_open, "Keep the terminal open after the command has finished executing", nullptr, 'k'); - args_parser.parse(arguments); if (keep_open && command_to_execute.is_empty()) { @@ -257,223 +45,31 @@ ErrorOr serenity_main(Main::Arguments arguments) return 1; } - int ptm_fd; - pid_t shell_pid = forkpty(&ptm_fd, nullptr, nullptr, nullptr); - if (shell_pid < 0) - return Error::from_errno(errno); - - // We're the child process; run the startup command. - if (shell_pid == 0) { - if (!command_to_execute.is_empty()) - TRY(run_command(command_to_execute, keep_open)); - else - TRY(run_command(Config::read_string("Terminal"sv, "Startup"sv, "Command"sv, ""sv), false)); - VERIFY_NOT_REACHED(); - } - - auto ptsname = TRY(Core::System::ptsname(ptm_fd)); - TRY(utmp_update(ptsname, shell_pid, true)); - - auto app_icon = GUI::Icon::default_icon("app-terminal"sv); - - auto window = GUI::Window::construct(); - window->set_title("Terminal"); - window->set_obey_widget_min_size(false); - - auto terminal = window->set_main_widget(ptm_fd, true); - terminal->set_startup_process_id(shell_pid); - - terminal->on_command_exit = [&] { - app->quit(0); - }; - terminal->on_title_change = [&](auto title) { - window->set_title(title); - }; - terminal->on_terminal_size_change = [&](auto size) { - window->resize(size); - }; - terminal->apply_size_increments_to_window(*window); - window->set_icon(app_icon.bitmap_for_size(16)); - - Config::monitor_domain("Terminal"); - auto should_confirm_close = Config::read_bool("Terminal"sv, "Terminal"sv, "ConfirmClose"sv, true); - TerminalChangeListener listener { terminal }; - - auto bell = Config::read_string("Terminal"sv, "Window"sv, "Bell"sv, "Visible"sv); - if (bell == "AudibleBeep") { - terminal->set_bell_mode(VT::TerminalWidget::BellMode::AudibleBeep); - } else if (bell == "Disabled") { - terminal->set_bell_mode(VT::TerminalWidget::BellMode::Disabled); - } else { - terminal->set_bell_mode(VT::TerminalWidget::BellMode::Visible); - } - - auto automark = Config::read_string("Terminal"sv, "Terminal"sv, "AutoMark"sv, "MarkInteractiveShellPrompt"sv); - if (automark == "MarkNothing") { - terminal->set_auto_mark_mode(VT::TerminalWidget::AutoMarkMode::MarkNothing); - } else { - terminal->set_auto_mark_mode(VT::TerminalWidget::AutoMarkMode::MarkInteractiveShellPrompt); - } - - auto cursor_shape = VT::TerminalWidget::parse_cursor_shape(Config::read_string("Terminal"sv, "Cursor"sv, "Shape"sv, "Block"sv)).value_or(VT::CursorShape::Block); - terminal->set_cursor_shape(cursor_shape); - - auto cursor_blinking = Config::read_bool("Terminal"sv, "Cursor"sv, "Blinking"sv, true); - terminal->set_cursor_blinking(cursor_blinking); - - auto find_window = TRY(create_find_window(terminal)); - - auto new_opacity = Config::read_i32("Terminal"sv, "Window"sv, "Opacity"sv, 255); - terminal->set_opacity(new_opacity); - window->set_has_alpha_channel(new_opacity < 255); - - auto new_scrollback_size = Config::read_i32("Terminal"sv, "Terminal"sv, "MaxHistorySize"sv, terminal->max_history_size()); - terminal->set_max_history_size(new_scrollback_size); - - auto show_scroll_bar = Config::read_bool("Terminal"sv, "Terminal"sv, "ShowScrollBar"sv, true); - terminal->set_show_scrollbar(show_scroll_bar); - - auto open_settings_action = GUI::Action::create("Terminal &Settings", TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/settings.png"sv)), - [&](auto&) { - GUI::Process::spawn_or_show_error(window, "/bin/TerminalSettings"sv); - }); - - terminal->context_menu().add_separator(); - terminal->context_menu().add_action(open_settings_action); - - auto file_menu = window->add_menu("&File"_string); - file_menu->add_action(GUI::Action::create("Open New &Terminal", { Mod_Ctrl | Mod_Shift, Key_N }, TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-terminal.png"sv)), [&](auto&) { - GUI::Process::spawn_or_show_error(window, "/bin/Terminal"sv); - })); - - file_menu->add_action(open_settings_action); - file_menu->add_separator(); - - auto tty_has_foreground_process = [&] { - pid_t fg_pid = tcgetpgrp(ptm_fd); - return fg_pid != -1 && fg_pid != shell_pid; - }; - - auto shell_child_process_count = [&] { - int background_process_count = 0; - Core::Directory::for_each_entry(String::formatted("/proc/{}/children", shell_pid).release_value_but_fixme_should_propagate_errors(), Core::DirIterator::Flags::SkipParentAndBaseDir, [&](auto&, auto&) { - ++background_process_count; - return IterationDecision::Continue; - }).release_value_but_fixme_should_propagate_errors(); - return background_process_count; - }; - - auto check_terminal_quit = [&]() -> GUI::Dialog::ExecResult { - if (!should_confirm_close) - return GUI::MessageBox::ExecResult::OK; - Optional close_message; - auto title = "Running Process"sv; - if (tty_has_foreground_process()) { - close_message = "Close Terminal and kill its foreground process?"_string; - } else { - auto child_process_count = shell_child_process_count(); - if (child_process_count > 1) { - title = "Running Processes"sv; - close_message = String::formatted("Close Terminal and kill its {} background processes?", child_process_count).release_value_but_fixme_should_propagate_errors(); - } else if (child_process_count == 1) { - close_message = "Close Terminal and kill its background process?"_string; - } - } - if (close_message.has_value()) - return GUI::MessageBox::show(window, *close_message, title, GUI::MessageBox::Type::Warning, GUI::MessageBox::InputType::OKCancel); - return GUI::MessageBox::ExecResult::OK; - }; - - file_menu->add_action(GUI::CommonActions::make_quit_action( - [&](auto&) { - dbgln("Terminal: Quit menu activated!"); - if (check_terminal_quit() == GUI::MessageBox::ExecResult::OK) - GUI::Application::the()->quit(); - }, - GUI::CommonActions::QuitAltShortcut::None)); - - auto edit_menu = window->add_menu("&Edit"_string); - edit_menu->add_action(terminal->copy_action()); - edit_menu->add_action(terminal->paste_action()); - edit_menu->add_separator(); - edit_menu->add_action(GUI::Action::create("&Find...", { Mod_Ctrl | Mod_Shift, Key_F }, TRY(Gfx::Bitmap::load_from_file("/res/icons/16x16/find.png"sv)), - [&](auto&) { - find_window->show(); - find_window->move_to_front(); - })); - - auto view_menu = window->add_menu("&View"_string); - view_menu->add_action(GUI::CommonActions::make_fullscreen_action([&](auto&) { - window->set_fullscreen(!window->is_fullscreen()); - })); - view_menu->add_action(terminal->clear_including_history_action()); - view_menu->add_action(terminal->clear_to_previous_mark_action()); - - auto adjust_font_size = [&](float adjustment, Gfx::Font::AllowInexactSizeMatch preference) { - auto& font = terminal->font(); - auto new_size = max(5, font.presentation_size() + adjustment); - if (auto new_font = Gfx::FontDatabase::the().get(font.family(), new_size, font.weight(), font.width(), font.slope(), preference)) { - terminal->set_font_and_resize_to_fit(*new_font); - terminal->apply_size_increments_to_window(*window); - window->resize(terminal->size()); - } - }; - - view_menu->add_separator(); - view_menu->add_action(GUI::CommonActions::make_zoom_in_action([&](auto&) { - adjust_font_size(1, Gfx::Font::AllowInexactSizeMatch::Larger); - })); - view_menu->add_action(GUI::CommonActions::make_zoom_out_action([&](auto&) { - adjust_font_size(-1, Gfx::Font::AllowInexactSizeMatch::Smaller); - })); - - auto help_menu = window->add_menu("&Help"_string); - help_menu->add_action(GUI::CommonActions::make_command_palette_action(window)); - help_menu->add_action(GUI::CommonActions::make_help_action([](auto&) { - Desktop::Launcher::open(URL::create_with_file_scheme("/usr/share/man/man1/Applications/Terminal.md"), "/bin/Help"); - })); - help_menu->add_action(GUI::CommonActions::make_about_action("Terminal"_string, app_icon, window)); - - window->on_close_request = [&]() -> GUI::Window::CloseRequestDecision { - if (check_terminal_quit() == GUI::MessageBox::ExecResult::OK) - return GUI::Window::CloseRequestDecision::Close; - return GUI::Window::CloseRequestDecision::StayOpen; - }; - - window->on_input_preemption_change = [&](bool is_preempted) { - terminal->set_logical_focus(!is_preempted); - }; + // Read the user's shell before locking unveil so we can unveil the correct executable. + auto user_shell = TRY(String::from_byte_string(TRY(Core::Account::self(Core::Account::Read::PasswdOnly)).shell())); + if (user_shell.is_empty()) + user_shell = "/bin/Shell"_string; TRY(Core::System::unveil("/res", "r")); TRY(Core::System::unveil("/bin", "r")); TRY(Core::System::unveil("/proc", "r")); + TRY(Core::System::unveil("/dev/pts", "rw")); + TRY(Core::System::unveil("/dev/ptmx", "rw")); + TRY(Core::System::unveil(user_shell.bytes_as_string_view(), "x"sv)); TRY(Core::System::unveil("/bin/Terminal", "x")); TRY(Core::System::unveil("/bin/TerminalSettings", "x")); TRY(Core::System::unveil("/bin/utmpupdate", "x")); + TRY(Core::System::unveil("/etc/passwd", "r")); TRY(Core::System::unveil("/etc/FileIconProvider.ini", "r")); TRY(Core::System::unveil("/tmp/session/%sid/portal/launch", "rw")); TRY(Core::System::unveil("/dev/beep", "rw")); TRY(Core::System::unveil(nullptr, nullptr)); - auto modified_state_check_timer = Core::Timer::create_repeating(500, [&] { - window->set_modified(tty_has_foreground_process() || shell_child_process_count() > 0); - }); - - listener.on_confirm_close_changed = [&](bool confirm_close) { - if (confirm_close) { - modified_state_check_timer->start(); - } else { - modified_state_check_timer->stop(); - window->set_modified(false); - } - should_confirm_close = confirm_close; - }; - + auto window = TerminalWindow::construct(); + TRY(window->initialize(command_to_execute, keep_open)); window->show(); - if (should_confirm_close) - modified_state_check_timer->start(); + int result = app->exec(); - dbgln("Exiting terminal, updating utmp"); - TRY(utmp_update(ptsname, 0, false)); + TRY(window->cleanup()); return result; } diff --git a/Userland/DevTools/HackStudio/HackStudioWidget.cpp b/Userland/DevTools/HackStudio/HackStudioWidget.cpp index 331d205fc500bf..09fb1e0173928e 100644 --- a/Userland/DevTools/HackStudio/HackStudioWidget.cpp +++ b/Userland/DevTools/HackStudio/HackStudioWidget.cpp @@ -756,11 +756,6 @@ void HackStudioWidget::add_new_editor_tab_widget(GUI::Widget& parent) current_editor().set_focus(true); }; - tab_widget->on_middle_click = [](auto& widget) { - auto& wrapper = static_cast(widget); - wrapper.on_tab_close_request(wrapper); - }; - tab_widget->on_tab_close_click = [](auto& widget) { auto& wrapper = static_cast(widget); wrapper.on_tab_close_request(wrapper); diff --git a/Userland/DevTools/SQLStudio/MainWidget.cpp b/Userland/DevTools/SQLStudio/MainWidget.cpp index d46f4fa1b9dd62..08de58394d6d1a 100644 --- a/Userland/DevTools/SQLStudio/MainWidget.cpp +++ b/Userland/DevTools/SQLStudio/MainWidget.cpp @@ -222,6 +222,9 @@ ErrorOr MainWidget::initialize() on_editor_change(); }; + m_tab_widget->set_add_tab_button_enabled(true); + m_tab_widget->on_add_tab_button_click = [&] { open_new_script(); }; + m_action_tab_widget = find_descendant_of_type_named("action_tab_widget"sv); m_query_results_widget = m_action_tab_widget->add_tab("Results"_string); diff --git a/Userland/Libraries/LibGUI/TabWidget.cpp b/Userland/Libraries/LibGUI/TabWidget.cpp index b347f7444f9916..d305e98604f512 100644 --- a/Userland/Libraries/LibGUI/TabWidget.cpp +++ b/Userland/Libraries/LibGUI/TabWidget.cpp @@ -30,6 +30,7 @@ TabWidget::TabWidget() REGISTER_MARGINS_PROPERTY("container_margins", container_margins, set_container_margins); REGISTER_BOOL_PROPERTY("show_close_buttons", close_button_enabled, set_close_button_enabled); + REGISTER_BOOL_PROPERTY("show_add_tab_button", add_tab_button_enabled, set_add_tab_button_enabled); REGISTER_BOOL_PROPERTY("show_tab_bar", is_bar_visible, set_bar_visible); REGISTER_BOOL_PROPERTY("reorder_allowed", reorder_allowed, set_reorder_allowed); REGISTER_BOOL_PROPERTY("uniform_tabs", uniform_tabs, set_uniform_tabs); @@ -100,7 +101,7 @@ void TabWidget::update_focus_policy() { FocusPolicy policy; if (is_bar_visible() && !m_tabs.is_empty()) - policy = FocusPolicy::TabFocus; + policy = FocusPolicy::ClickFocus; else policy = FocusPolicy::NoFocus; set_focus_policy(policy); @@ -298,6 +299,15 @@ void TabWidget::paint_event(PaintEvent& event) } } + if (m_add_tab_button_enabled && !has_vertical_tabs()) { + auto add_rect = add_button_rect(); + if (m_hovered_add_button) + Gfx::StylePainter::paint_frame(painter, add_rect, palette(), m_pressed_add_button ? Gfx::FrameStyle::SunkenPanel : Gfx::FrameStyle::RaisedPanel); + auto center = add_rect.center().translated(-1, -1); + painter.draw_line({ center.x() - 3, center.y() }, { center.x() + 3, center.y() }, palette().button_text()); + painter.draw_line({ center.x(), center.y() - 3 }, { center.x(), center.y() + 3 }, palette().button_text()); + } + for (size_t i = 0; i < m_tabs.size(); ++i) { if (m_tabs[i].widget != m_active_widget) continue; @@ -384,6 +394,8 @@ int TabWidget::uniform_tab_width() const return tab_width; int available_width = width() - bar_margin() * 2; + if (m_add_tab_button_enabled) + available_width -= 4 + (bar_height() - 4) + bar_margin(); if (total_tab_width > available_width) tab_width = available_width / m_tabs.size(); return max(tab_width, m_min_tab_width); @@ -397,6 +409,31 @@ void TabWidget::set_bar_visible(bool bar_visible) update_bar(); } +void TabWidget::set_add_tab_button_enabled(bool enabled) +{ + m_add_tab_button_enabled = enabled; + update_bar(); +} + +int TabWidget::compute_tab_width(size_t index) const +{ + int close_button_offset = m_close_button_enabled ? 16 : 0; + if (m_uniform_tabs) + return uniform_tab_width(); + if (has_vertical_tabs()) + return m_tabs[index].width(font()) + close_button_offset; + // Auto-shrink all tabs evenly when total natural width overflows available bar space. + int total = 0; + for (auto& tab : m_tabs) + total += tab.width(font()) + close_button_offset; + int available = width() - bar_margin() * 2; + if (m_add_tab_button_enabled) + available -= 3 + (bar_height() - 4) + bar_margin(); + if (total > available && !m_tabs.is_empty()) + return max(available / (int)m_tabs.size(), m_min_tab_width); + return m_tabs[index].width(font()) + close_button_offset; +} + Gfx::IntRect TabWidget::button_rect(size_t index) const { if (this->has_vertical_tabs()) @@ -424,13 +461,10 @@ Gfx::IntRect TabWidget::vertical_button_rect(size_t index) const Gfx::IntRect TabWidget::horizontal_button_rect(size_t index) const { int x_offset = bar_margin(); - int close_button_offset = m_close_button_enabled ? 16 : 0; - for (size_t i = 0; i < index; ++i) { - auto tab_width = m_uniform_tabs ? uniform_tab_width() : m_tabs[i].width(font()) + close_button_offset; - x_offset += tab_width; + x_offset += compute_tab_width(i); } - Gfx::IntRect rect { x_offset, 0, m_uniform_tabs ? uniform_tab_width() : m_tabs[index].width(font()) + close_button_offset, bar_height() }; + Gfx::IntRect rect { x_offset, 0, compute_tab_width(index), bar_height() }; if (m_tabs[index].widget != m_active_widget) { rect.translate_by(0, m_tab_position == TabPosition::Top ? 2 : 0); rect.set_height(rect.height() - 2); @@ -453,6 +487,19 @@ Gfx::IntRect TabWidget::close_button_rect(size_t index) const return close_button_rect; } +Gfx::IntRect TabWidget::add_button_rect() const +{ + int x = bar_margin(); + for (size_t i = 0; i < m_tabs.size(); ++i) { + x += compute_tab_width(i); + } + int size = bar_height() - 4; + int x_offset = m_tabs.is_empty() ? 0 : 4; + Gfx::IntRect rect { x + x_offset, (bar_height() - size) / 2, size, size }; + rect.translate_by(bar_rect().location()); + return rect; +} + int TabWidget::TabData::width(Gfx::Font const& font) const { auto width = 16 + font.width_rounded_up(title) + (icon ? (16 + 4) : 0); @@ -470,6 +517,14 @@ int TabWidget::TabData::width(Gfx::Font const& font) const void TabWidget::mousedown_event(MouseEvent& event) { + if (m_add_tab_button_enabled && !has_vertical_tabs() && event.button() == MouseButton::Primary) { + if (add_button_rect().contains(event.position())) { + m_pressed_add_button = true; + update_bar(); + return; + } + } + for (size_t i = 0; i < m_tabs.size(); ++i) { auto button_rect = this->button_rect(i); auto close_button_rect = this->close_button_rect(i); @@ -488,8 +543,8 @@ void TabWidget::mousedown_event(MouseEvent& event) } else if (event.button() == MouseButton::Middle) { auto* widget = m_tabs[i].widget; deferred_invoke([this, widget] { - if (on_middle_click && widget) - on_middle_click(*widget); + if (on_tab_close_click && widget) + on_tab_close_click(*widget); }); } return; @@ -501,6 +556,14 @@ void TabWidget::mouseup_event(MouseEvent& event) if (event.button() != MouseButton::Primary) return; + if (m_pressed_add_button) { + m_pressed_add_button = false; + update_bar(); + if (add_button_rect().contains(event.position()) && on_add_tab_button_click) + on_add_tab_button_click(); + return; + } + if (m_dragging_active_tab) { m_dragging_active_tab = false; update_bar(); @@ -534,6 +597,14 @@ void TabWidget::mousemove_event(MouseEvent& event) return; } + if (m_add_tab_button_enabled && !has_vertical_tabs()) { + bool hovered = add_button_rect().contains(event.position()); + if (hovered != m_hovered_add_button) { + m_hovered_add_button = hovered; + update_bar(); + } + } + for (size_t i = 0; i < m_tabs.size(); ++i) { auto button_rect = this->button_rect(i); auto close_button_rect = this->close_button_rect(i); @@ -568,9 +639,10 @@ void TabWidget::mousewheel_event(MouseEvent& event) void TabWidget::leave_event(Core::Event&) { - if (m_hovered_tab_index.has_value() || m_hovered_close_button_index.has_value()) { + if (m_hovered_tab_index.has_value() || m_hovered_close_button_index.has_value() || m_hovered_add_button) { m_hovered_tab_index = {}; m_hovered_close_button_index = {}; + m_hovered_add_button = false; update_bar(); } } @@ -717,11 +789,15 @@ void TabWidget::keydown_event(KeyEvent& event) if (is_focused()) { if (!event.modifiers() && event.key() == Key_Left) { activate_previous_tab(); + // if (m_active_widget) + // m_active_widget->set_focus(true); event.accept(); return; } if (!event.modifiers() && event.key() == Key_Right) { activate_next_tab(); + // if (m_active_widget) + // m_active_widget->set_focus(true); event.accept(); return; } diff --git a/Userland/Libraries/LibGUI/TabWidget.h b/Userland/Libraries/LibGUI/TabWidget.h index e50e0f595ba749..e5dfc41f289384 100644 --- a/Userland/Libraries/LibGUI/TabWidget.h +++ b/Userland/Libraries/LibGUI/TabWidget.h @@ -92,15 +92,18 @@ class TabWidget : public Widget { void set_close_button_enabled(bool close_button_enabled) { m_close_button_enabled = close_button_enabled; } bool close_button_enabled() const { return m_close_button_enabled; } + void set_add_tab_button_enabled(bool add_tab_button_enabled); + bool add_tab_button_enabled() const { return m_add_tab_button_enabled; } + void set_reorder_allowed(bool reorder_allowed) { m_reorder_allowed = reorder_allowed; } bool reorder_allowed() const { return m_reorder_allowed; } Function on_tab_count_change; Function on_change; - Function on_middle_click; Function on_tab_close_click; Function on_context_menu_request; Function on_double_click; + Function on_add_tab_button_click; protected: TabWidget(); @@ -123,8 +126,10 @@ class TabWidget : public Widget { Gfx::IntRect vertical_button_rect(size_t index) const; Gfx::IntRect horizontal_button_rect(size_t index) const; Gfx::IntRect close_button_rect(size_t index) const; + Gfx::IntRect add_button_rect() const; Gfx::IntRect bar_rect() const; Gfx::IntRect container_rect() const; + int compute_tab_width(size_t index) const; void update_bar(); void update_focus_policy(); int bar_margin() const { return 2; } @@ -149,7 +154,9 @@ class TabWidget : public Widget { bool m_uniform_tabs { false }; bool m_bar_visible { true }; bool m_close_button_enabled { false }; - + bool m_add_tab_button_enabled { false }; + bool m_hovered_add_button { false }; + bool m_pressed_add_button { false }; int m_max_tab_width { 160 }; int m_min_tab_width { 24 }; diff --git a/Userland/Libraries/LibVT/TerminalWidget.cpp b/Userland/Libraries/LibVT/TerminalWidget.cpp index ea53d4de9d0e46..9af48682519f79 100644 --- a/Userland/Libraries/LibVT/TerminalWidget.cpp +++ b/Userland/Libraries/LibVT/TerminalWidget.cpp @@ -85,6 +85,15 @@ void TerminalWidget::set_pty_master_fd(int fd) }; } +TerminalWidget::~TerminalWidget() +{ + if (m_ptm_fd != -1) { + m_notifier = nullptr; + close(m_ptm_fd); + m_ptm_fd = -1; + } +} + TerminalWidget::TerminalWidget(int ptm_fd, bool automatic_size_policy) : m_terminal(*this) , m_automatic_size_policy(automatic_size_policy) diff --git a/Userland/Libraries/LibVT/TerminalWidget.h b/Userland/Libraries/LibVT/TerminalWidget.h index 498f31481b3832..c433822a119577 100644 --- a/Userland/Libraries/LibVT/TerminalWidget.h +++ b/Userland/Libraries/LibVT/TerminalWidget.h @@ -28,7 +28,7 @@ class TerminalWidget final C_OBJECT(TerminalWidget); public: - virtual ~TerminalWidget() override = default; + virtual ~TerminalWidget() override; void set_pty_master_fd(int fd); void inject_string(StringView string)