Serenity Operating System
1/*
2 * Copyright (c) 2021, Spencer Dixon <spencercdixon@gmail.com>
3 * Copyright (c) 2022-2023, the SerenityOS developers.
4 *
5 * SPDX-License-Identifier: BSD-2-Clause
6 */
7
8#include "Providers.h"
9#include <AK/Array.h>
10#include <AK/DeprecatedString.h>
11#include <AK/Error.h>
12#include <AK/LexicalPath.h>
13#include <AK/QuickSort.h>
14#include <AK/Try.h>
15#include <LibCore/Debounce.h>
16#include <LibCore/LockFile.h>
17#include <LibCore/System.h>
18#include <LibDesktop/Launcher.h>
19#include <LibGUI/Action.h>
20#include <LibGUI/Application.h>
21#include <LibGUI/BoxLayout.h>
22#include <LibGUI/Event.h>
23#include <LibGUI/Icon.h>
24#include <LibGUI/ImageWidget.h>
25#include <LibGUI/Label.h>
26#include <LibGUI/Menu.h>
27#include <LibGUI/Painter.h>
28#include <LibGUI/TextBox.h>
29#include <LibGfx/Palette.h>
30#include <LibMain/Main.h>
31#include <LibThreading/Mutex.h>
32#include <string.h>
33#include <unistd.h>
34
35namespace Assistant {
36
37struct AppState {
38 Optional<size_t> selected_index;
39 Vector<NonnullRefPtr<Result const>> results;
40 size_t visible_result_count { 0 };
41
42 Threading::Mutex lock;
43 DeprecatedString last_query;
44};
45
46class ResultRow final : public GUI::Button {
47 C_OBJECT(ResultRow)
48 ResultRow()
49 {
50 set_greedy_for_hits(true);
51 set_fixed_height(36);
52 set_text_alignment(Gfx::TextAlignment::CenterLeft);
53 set_button_style(Gfx::ButtonStyle::Coolbar);
54 set_focus_policy(GUI::FocusPolicy::NoFocus);
55
56 on_context_menu_request = [this](auto& event) {
57 if (!m_context_menu) {
58 m_context_menu = GUI::Menu::construct();
59
60 if (LexicalPath path { text().to_deprecated_string() }; path.is_absolute()) {
61 m_context_menu->add_action(GUI::Action::create("&Show in File Manager", MUST(Gfx::Bitmap::load_from_file("/res/icons/16x16/app-file-manager.png"sv)), [=](auto&) {
62 Desktop::Launcher::open(URL::create_with_file_scheme(path.dirname(), path.basename()));
63 }));
64 m_context_menu->add_separator();
65 }
66
67 m_context_menu->add_action(GUI::Action::create("&Copy as Text", MUST(Gfx::Bitmap::load_from_file("/res/icons/16x16/edit-copy.png"sv)), [&](auto&) {
68 GUI::Clipboard::the().set_plain_text(text());
69 }));
70 }
71 m_context_menu->popup(event.screen_position());
72 };
73 }
74
75 RefPtr<GUI::Menu> m_context_menu;
76};
77
78template<size_t ProviderCount>
79class Database {
80public:
81 explicit Database(AppState& state, Array<NonnullRefPtr<Provider>, ProviderCount>& providers)
82 : m_state(state)
83 , m_providers(providers)
84 {
85 }
86
87 Function<void(Vector<NonnullRefPtr<Result const>>)> on_new_results;
88
89 void search(DeprecatedString const& query)
90 {
91 auto should_display_precached_results = false;
92 for (size_t i = 0; i < ProviderCount; ++i) {
93 auto& result_array = m_result_cache.ensure(query);
94 if (result_array.at(i) == nullptr) {
95 m_providers[i]->query(query, [this, query, i](auto results) {
96 {
97 Threading::MutexLocker db_locker(m_mutex);
98 auto& result_array = m_result_cache.ensure(query);
99 if (result_array.at(i) != nullptr)
100 return;
101 result_array[i] = make<Vector<NonnullRefPtr<Result>>>(results);
102 }
103 on_result_cache_updated();
104 });
105 } else {
106 should_display_precached_results = true;
107 }
108 }
109 if (should_display_precached_results)
110 on_result_cache_updated();
111 }
112
113private:
114 void on_result_cache_updated()
115 {
116 Threading::MutexLocker state_locker(m_state.lock);
117 auto new_results = m_result_cache.find(m_state.last_query);
118 if (new_results == m_result_cache.end())
119 return;
120
121 Vector<NonnullRefPtr<Result const>> all_results;
122 for (auto const& results_for_provider : new_results->value) {
123 if (results_for_provider == nullptr)
124 continue;
125 for (auto const& result : *results_for_provider) {
126 all_results.append(result);
127 }
128 }
129
130 dual_pivot_quick_sort(all_results, 0, static_cast<int>(all_results.size() - 1), [](auto& a, auto& b) {
131 return a->score() > b->score();
132 });
133
134 on_new_results(all_results);
135 }
136
137 AppState& m_state;
138
139 Array<NonnullRefPtr<Provider>, ProviderCount> m_providers;
140
141 Threading::Mutex m_mutex;
142 HashMap<DeprecatedString, Array<OwnPtr<Vector<NonnullRefPtr<Result>>>, ProviderCount>> m_result_cache;
143};
144
145}
146
147static constexpr size_t MAX_SEARCH_RESULTS = 6;
148
149ErrorOr<int> serenity_main(Main::Arguments arguments)
150{
151 TRY(Core::System::pledge("stdio recvfd sendfd rpath cpath unix proc exec thread"));
152
153 Core::LockFile lockfile("/tmp/lock/assistant.lock");
154
155 if (!lockfile.is_held()) {
156 if (lockfile.error_code()) {
157 warnln("Core::LockFile: {}", strerror(lockfile.error_code()));
158 return 1;
159 }
160
161 // Another assistant is open, so exit silently.
162 return 0;
163 }
164
165 auto app = TRY(GUI::Application::try_create(arguments));
166 auto window = GUI::Window::construct();
167 window->set_minimizable(false);
168
169 Assistant::AppState app_state;
170 Array<NonnullRefPtr<Assistant::Provider>, 5> providers = {
171 make_ref_counted<Assistant::AppProvider>(),
172 make_ref_counted<Assistant::CalculatorProvider>(),
173 make_ref_counted<Assistant::TerminalProvider>(),
174 make_ref_counted<Assistant::URLProvider>(),
175 make_ref_counted<Assistant::FileProvider>()
176 };
177 Assistant::Database db { app_state, providers };
178
179 auto container = TRY(window->set_main_widget<GUI::Frame>());
180 container->set_fill_with_background_color(true);
181 container->set_frame_shape(Gfx::FrameShape::Window);
182 container->set_layout<GUI::VerticalBoxLayout>(8);
183
184 auto& text_box = container->add<GUI::TextBox>();
185 auto& results_container = container->add<GUI::Widget>();
186 results_container.set_layout<GUI::VerticalBoxLayout>();
187
188 auto mark_selected_item = [&]() {
189 for (size_t i = 0; i < app_state.visible_result_count; ++i) {
190 auto& row = static_cast<Assistant::ResultRow&>(results_container.child_widgets()[i]);
191 row.set_font_weight(i == app_state.selected_index ? 700 : 400);
192 }
193 };
194
195 text_box.on_change = Core::debounce([&]() {
196 {
197 Threading::MutexLocker locker(app_state.lock);
198 if (app_state.last_query == text_box.text())
199 return;
200
201 app_state.last_query = text_box.text();
202 }
203
204 db.search(text_box.text());
205 },
206 5);
207 text_box.on_return_pressed = [&]() {
208 if (!app_state.selected_index.has_value())
209 return;
210 lockfile.release();
211 app_state.results[app_state.selected_index.value()]->activate();
212 GUI::Application::the()->quit();
213 };
214 text_box.on_up_pressed = [&]() {
215 if (!app_state.visible_result_count)
216 return;
217 auto new_selected_index = app_state.selected_index.value_or(0);
218 if (new_selected_index == 0)
219 new_selected_index = app_state.visible_result_count - 1;
220 else if (new_selected_index > 0)
221 new_selected_index -= 1;
222
223 app_state.selected_index = new_selected_index;
224 mark_selected_item();
225 };
226 text_box.on_down_pressed = [&]() {
227 if (!app_state.visible_result_count)
228 return;
229
230 auto new_selected_index = app_state.selected_index.value_or(0);
231 if ((app_state.visible_result_count - 1) == new_selected_index)
232 new_selected_index = 0;
233 else if (app_state.visible_result_count > new_selected_index)
234 new_selected_index += 1;
235
236 app_state.selected_index = new_selected_index;
237 mark_selected_item();
238 };
239 text_box.on_escape_pressed = []() {
240 GUI::Application::the()->quit();
241 };
242 window->on_active_window_change = [](bool is_active_window) {
243 if (!is_active_window)
244 GUI::Application::the()->quit();
245 };
246
247 auto update_ui_timer = TRY(Core::Timer::create_single_shot(10, [&] {
248 results_container.remove_all_children();
249 results_container.layout()->set_margins(app_state.visible_result_count ? GUI::Margins { 4, 0, 0, 0 } : GUI::Margins { 0 });
250
251 for (size_t i = 0; i < app_state.visible_result_count; ++i) {
252 auto& result = app_state.results[i];
253 auto& match = results_container.add<Assistant::ResultRow>();
254 match.set_icon(result->bitmap());
255 match.set_text(String::from_deprecated_string(result->title()).release_value_but_fixme_should_propagate_errors());
256 match.set_tooltip(move(result->tooltip()));
257 match.on_click = [&result](auto) {
258 result->activate();
259 GUI::Application::the()->quit();
260 };
261 }
262
263 mark_selected_item();
264 Core::deferred_invoke([&] { window->resize(GUI::Desktop::the().rect().width() / 3, {}); });
265 }));
266
267 db.on_new_results = [&](auto results) {
268 if (results.is_empty())
269 app_state.selected_index = {};
270 else
271 app_state.selected_index = 0;
272 app_state.results = results;
273 app_state.visible_result_count = min(results.size(), MAX_SEARCH_RESULTS);
274
275 update_ui_timer->restart();
276 };
277
278 window->set_window_type(GUI::WindowType::Popup);
279 window->set_forced_shadow(true);
280 window->resize(GUI::Desktop::the().rect().width() / 3, {});
281 window->center_on_screen();
282 window->move_to(window->x(), window->y() - (GUI::Desktop::the().rect().height() * 0.33));
283 window->show();
284
285 return app->exec();
286}