Line data Source code
1 : #include <SDL2/SDL.h>
2 : #include <imgui.h>
3 :
4 : #include <cstdio>
5 : #include <string>
6 :
7 : #include "audio/effects/utility/tuner.h"
8 : #include "gui/dialogs/file_dialog.h"
9 : #include "gui/gui_manager.h"
10 : #include "gui/pedalboard/pedal_board.h"
11 : #include "gui/theme/theme.h"
12 : #include "preset_manager.h"
13 : #ifdef __APPLE__
14 : #include <TargetConditionals.h>
15 : #endif
16 : #ifdef __EMSCRIPTEN__
17 : #include <emscripten.h>
18 : #endif
19 :
20 : // clang-format off
21 : #if defined(_WIN32)
22 : #include <windows.h>
23 : #include <shellapi.h>
24 : #elif defined(__APPLE__) && !TARGET_OS_IOS
25 : #include <fcntl.h>
26 : #include <sys/wait.h>
27 : #include <unistd.h>
28 : #elif defined(__linux__)
29 : #include <fcntl.h>
30 : #include <sys/wait.h>
31 : #include <unistd.h>
32 : #endif
33 : // clang-format on
34 :
35 : namespace Amplitron {
36 :
37 : // Safe URL opener that avoids shell injection
38 0 : static void open_url_safe(const std::string& url) {
39 : #if defined(_WIN32)
40 0 : ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
41 : #elif defined(__APPLE__) && !TARGET_OS_IOS
42 : // Use fork+exec to invoke open without shell
43 : int pipefd[2];
44 0 : if (pipe(pipefd) != 0) return;
45 0 : pid_t pid = fork();
46 0 : if (pid < 0) {
47 0 : close(pipefd[0]);
48 0 : close(pipefd[1]);
49 0 : return;
50 : }
51 0 : if (pid == 0) {
52 0 : close(pipefd[0]);
53 0 : close(pipefd[1]);
54 0 : int devnull = open("/dev/null", O_WRONLY);
55 0 : if (devnull >= 0) {
56 0 : dup2(devnull, STDERR_FILENO);
57 0 : close(devnull);
58 0 : }
59 0 : execl("/usr/bin/open", "open", url.c_str(), nullptr);
60 0 : _exit(1);
61 : }
62 0 : close(pipefd[0]);
63 0 : close(pipefd[1]);
64 0 : int status = 0;
65 0 : waitpid(pid, &status, 0);
66 : #elif defined(__linux__)
67 : // Use fork+exec to invoke xdg-open without shell
68 : int pipefd[2];
69 0 : if (pipe(pipefd) != 0) return;
70 0 : pid_t pid = fork();
71 0 : if (pid < 0) {
72 0 : close(pipefd[0]);
73 0 : close(pipefd[1]);
74 0 : return;
75 : }
76 0 : if (pid == 0) {
77 0 : close(pipefd[0]);
78 0 : close(pipefd[1]);
79 0 : int devnull = open("/dev/null", O_WRONLY);
80 0 : if (devnull >= 0) {
81 0 : dup2(devnull, STDERR_FILENO);
82 0 : close(devnull);
83 : }
84 0 : execl("/usr/bin/xdg-open", "xdg-open", url.c_str(), nullptr);
85 0 : _exit(1);
86 : }
87 0 : close(pipefd[0]);
88 0 : close(pipefd[1]);
89 0 : int status = 0;
90 0 : waitpid(pid, &status, 0);
91 : #endif
92 0 : }
93 :
94 6 : void GuiManager::render_menu_bar() {
95 6 : if (ImGui::BeginMainMenuBar()) {
96 6 : if (ImGui::BeginMenu("File")) {
97 0 : if (ImGui::MenuItem("New Preset...")) {
98 0 : gui_presets_.begin_new_preset();
99 0 : show_save_preset_ = true;
100 0 : }
101 0 : if (ImGui::MenuItem("Save Preset...", "Ctrl+S")) {
102 0 : gui_presets_.begin_save_preset();
103 0 : show_save_preset_ = true;
104 0 : }
105 0 : if (ImGui::MenuItem("Load Preset...", "Ctrl+O")) {
106 0 : show_load_preset_ = true;
107 0 : gui_presets_.ensure_factory_presets();
108 0 : gui_presets_.refresh_presets(true);
109 0 : }
110 0 : bool has_selected_preset =
111 0 : gui_presets_.selected_preset_index() >= 0 &&
112 0 : gui_presets_.selected_preset_index() < gui_presets_.preset_count();
113 0 : if (ImGui::MenuItem("Delete Selected Preset", nullptr, false, has_selected_preset)) {
114 0 : ImGui::OpenPopup("Confirm Delete Preset");
115 0 : }
116 :
117 0 : if (ImGui::BeginPopupModal("Confirm Delete Preset", nullptr,
118 : ImGuiWindowFlags_AlwaysAutoResize)) {
119 0 : ImGui::Text(
120 : "Are you sure you want to delete the selected preset?\nThis action cannot be "
121 : "undone.");
122 0 : ImGui::Separator();
123 0 : if (ImGui::Button("Delete", ImVec2(120, 0))) {
124 0 : gui_presets_.delete_preset_by_index(gui_presets_.selected_preset_index());
125 0 : ImGui::CloseCurrentPopup();
126 0 : }
127 0 : ImGui::SameLine();
128 0 : if (ImGui::Button("Cancel", ImVec2(120, 0))) {
129 0 : ImGui::CloseCurrentPopup();
130 0 : }
131 0 : ImGui::EndPopup();
132 0 : }
133 0 : if (ImGui::MenuItem("Copy Preset to Clipboard")) {
134 0 : std::string json_string = gui_presets_.serialise_current_preset_to_json();
135 0 : if (!json_string.empty()) {
136 : #ifdef __EMSCRIPTEN__
137 : // Web build — use browser Clipboard API
138 : EM_ASM(
139 : {
140 : var text = UTF8ToString($0);
141 : navigator.clipboard.writeText(text)
142 : .then(function(){
143 : // success
144 : })
145 : .catch(function(err) {
146 : console.error("Clipboard write failed: ", err);
147 : });
148 : },
149 : json_string.c_str());
150 : #else
151 : // Native build — ImGui clipboard works fine
152 0 : ImGui::SetClipboardText(json_string.c_str());
153 : #endif
154 0 : toast_message_ = "Preset copied to clipboard!";
155 0 : toast_timer_ = 2.0f;
156 0 : } else {
157 0 : toast_message_ = "Failed to copy: empty preset.";
158 0 : toast_timer_ = 2.0f;
159 : }
160 0 : }
161 0 : ImGui::Separator();
162 : #ifndef AMPLITRON_NO_DESKTOP_SHELL
163 0 : if (ImGui::MenuItem("Change Presets Directory...")) {
164 0 : std::string chosen = show_folder_dialog("Select Presets Directory");
165 0 : if (!chosen.empty()) {
166 0 : PresetManager::set_presets_dir(chosen);
167 0 : PresetManager::save_config();
168 0 : gui_presets_.refresh_presets(false);
169 0 : }
170 0 : }
171 0 : if (ImGui::MenuItem("Reset to Default Presets Directory")) {
172 0 : ImGui::OpenPopup("Confirm Reset Presets Dir");
173 0 : }
174 :
175 0 : if (ImGui::BeginPopupModal("Confirm Reset Presets Dir", nullptr,
176 : ImGuiWindowFlags_AlwaysAutoResize)) {
177 0 : ImGui::Text("Reset presets directory to the default internal path?");
178 0 : ImGui::Separator();
179 0 : if (ImGui::Button("Reset", ImVec2(120, 0))) {
180 0 : PresetManager::set_presets_dir("");
181 0 : PresetManager::save_config();
182 0 : gui_presets_.refresh_presets(false);
183 0 : ImGui::CloseCurrentPopup();
184 0 : }
185 0 : ImGui::SameLine();
186 0 : if (ImGui::Button("Cancel", ImVec2(120, 0))) {
187 0 : ImGui::CloseCurrentPopup();
188 0 : }
189 0 : ImGui::EndPopup();
190 0 : }
191 : #endif
192 0 : ImGui::Separator();
193 0 : if (ImGui::MenuItem("Settings")) show_settings_ = true;
194 0 : ImGui::Separator();
195 0 : if (ImGui::MenuItem("Quit", "Alt+F4")) {
196 0 : SDL_Event quit_event;
197 0 : quit_event.type = SDL_QUIT;
198 0 : SDL_PushEvent(&quit_event);
199 0 : }
200 0 : ImGui::EndMenu();
201 0 : }
202 4 : if (ImGui::BeginMenu("Edit")) {
203 0 : bool can_undo = command_history_.can_undo();
204 0 : bool can_redo = command_history_.can_redo();
205 :
206 0 : const char* undo_label = command_history_.undo_description();
207 0 : char undo_buf[64] = "Undo";
208 0 : if (undo_label) snprintf(undo_buf, sizeof(undo_buf), "Undo %s", undo_label);
209 :
210 0 : const char* redo_label = command_history_.redo_description();
211 0 : char redo_buf[64] = "Redo";
212 0 : if (redo_label) snprintf(redo_buf, sizeof(redo_buf), "Redo %s", redo_label);
213 :
214 0 : if (ImGui::MenuItem(undo_buf, "Ctrl+Z", false, can_undo)) {
215 0 : if (command_history_.undo() && pedal_board_) {
216 0 : pedal_board_->rebuild_widgets();
217 0 : }
218 0 : }
219 0 : if (ImGui::MenuItem(redo_buf, "Ctrl+Shift+Z", false, can_redo)) {
220 0 : if (command_history_.redo() && pedal_board_) {
221 0 : pedal_board_->rebuild_widgets();
222 0 : }
223 0 : }
224 0 : ImGui::EndMenu();
225 0 : }
226 4 : if (ImGui::BeginMenu("Audio")) {
227 0 : if (engine_.is_running()) {
228 0 : if (ImGui::MenuItem("Stop Audio", "M")) engine_.stop();
229 0 : } else {
230 0 : if (ImGui::MenuItem("Start Audio", "M")) {
231 0 : engine_.restart();
232 0 : }
233 : }
234 0 : ImGui::Separator();
235 0 : if (ImGui::MenuItem("Restart Audio")) {
236 0 : engine_.restart();
237 0 : }
238 0 : ImGui::EndMenu();
239 0 : }
240 4 : if (ImGui::BeginMenu("Utilities")) {
241 0 : if (ImGui::MenuItem("Open Tuner", nullptr, show_tuner_)) {
242 0 : set_show_tuner(!show_tuner_);
243 0 : }
244 0 : if (ImGui::MenuItem("MIDI Settings", nullptr, show_midi_)) {
245 0 : show_midi_ = !show_midi_;
246 0 : }
247 0 : ImGui::EndMenu();
248 0 : }
249 :
250 : // Status bar (right-aligned items computed dynamically)
251 6 : float bar_w = ImGui::GetWindowWidth();
252 6 : float padding = 8.0f;
253 :
254 : // Build a vector of status items from right to left (for right-alignment)
255 22 : struct StatusItem {
256 : std::string label;
257 : bool is_clickable = false;
258 : };
259 6 : std::vector<StatusItem> items;
260 :
261 : // Preset status with dirty indicator
262 6 : std::string preset_label = "Preset: " + gui_presets_.current_preset_name();
263 6 : if (gui_presets_.is_dirty()) {
264 0 : preset_label += " *";
265 0 : }
266 6 : items.push_back({preset_label, false});
267 :
268 : // Sample rate (rightmost)
269 2 : char sr_buf[16];
270 6 : snprintf(sr_buf, sizeof(sr_buf), "%dHz", engine_.get_sample_rate());
271 6 : items.push_back({sr_buf, false});
272 :
273 : // Audio status (LIVE/STOPPED)
274 8 : items.push_back({engine_.is_running() ? "LIVE" : "STOPPED", false});
275 :
276 : // Recording indicator if recording
277 6 : if (engine_.recorder().is_recording()) {
278 0 : char rec_dur[32];
279 0 : snprintf(rec_dur, sizeof(rec_dur), "%.1fs", engine_.recorder().get_duration());
280 0 : items.push_back({rec_dur, false});
281 0 : items.push_back({"REC", false});
282 0 : }
283 :
284 : // MIDI status
285 8 : items.push_back({midi_manager_.is_port_open() ? "MIDI" : "MIDI", true});
286 :
287 : // Check for update (leftmost of right-aligned group)
288 6 : bool show_update = false;
289 6 : std::string update_version;
290 6 : std::string update_url;
291 6 : if (update_checker_.has_new_release()) {
292 0 : show_update = true;
293 0 : update_version = update_checker_.new_release_version();
294 0 : update_url = update_checker_.new_release_url();
295 0 : }
296 :
297 2 : if (show_update) {
298 0 : std::string release_text = "New Release Available: " + update_version;
299 0 : items.push_back({release_text, true}); // Clickable
300 0 : }
301 :
302 : // Measure widths and compute right-aligned positions
303 6 : float x_pos = bar_w - padding;
304 30 : for (auto it = items.rbegin(); it != items.rend(); ++it) {
305 24 : ImVec2 text_size = ImGui::CalcTextSize(it->label.c_str());
306 24 : x_pos -= text_size.x + padding;
307 8 : }
308 :
309 : // Render items from left to right
310 4 : x_pos = bar_w - padding;
311 30 : for (auto it = items.rbegin(); it != items.rend(); ++it) {
312 24 : ImVec2 text_size = ImGui::CalcTextSize(it->label.c_str());
313 24 : x_pos -= text_size.x + padding;
314 :
315 24 : ImGui::SameLine(x_pos);
316 :
317 24 : if (it->label.find("MIDI") == 0) {
318 6 : if (midi_manager_.is_port_open()) {
319 0 : ImGui::TextColored(Theme::Live(), "%s", it->label.c_str());
320 0 : } else {
321 6 : ImGui::TextColored(ImVec4(0.4f, 0.4f, 0.4f, 1.0f), "%s", it->label.c_str());
322 : }
323 6 : if (ImGui::IsItemHovered()) {
324 0 : ImGui::SetTooltip(midi_manager_.is_port_open()
325 : ? "MIDI Connected. Click for settings."
326 : : "MIDI Disconnected. Click for settings.");
327 0 : ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
328 0 : }
329 6 : if (ImGui::IsItemClicked()) {
330 0 : show_midi_ = !show_midi_;
331 0 : }
332 20 : } else if (it->label == "LIVE") {
333 2 : ImGui::TextColored(Theme::Live(), "%s", it->label.c_str());
334 18 : } else if (it->label == "STOPPED") {
335 4 : ImGui::TextColored(Theme::Stopped(), "%s", it->label.c_str());
336 12 : } else if (it->label == "REC") {
337 0 : float t = static_cast<float>(ImGui::GetTime());
338 0 : ImGui::TextColored(Theme::RecBlink(t), "%s", it->label.c_str());
339 12 : } else if (it->label.find("New Release") == 0) {
340 0 : ImGui::TextColored(Theme::GoldHot(), "%s", it->label.c_str());
341 0 : if (ImGui::IsItemHovered()) {
342 0 : ImGui::SetTooltip("Click to open release in browser");
343 0 : ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
344 0 : }
345 0 : if (ImGui::IsItemClicked()) {
346 0 : open_url_safe(update_url);
347 0 : }
348 0 : } else {
349 12 : ImGui::Text("%s", it->label.c_str());
350 : }
351 8 : }
352 :
353 6 : ImGui::EndMainMenuBar();
354 6 : }
355 :
356 : // Error banner when audio is stopped
357 8 : if (!engine_.is_running()) {
358 4 : ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.35f, 0.08f, 0.08f, 0.95f));
359 4 : ImGui::BeginChild("AudioErrorBanner", ImVec2(0, 36), true);
360 4 : ImGui::TextColored(Theme::Stopped(), "Audio stream is STOPPED.");
361 4 : ImGui::SameLine();
362 4 : if (ImGui::SmallButton("Restart Audio")) {
363 0 : engine_.restart();
364 0 : }
365 4 : ImGui::SameLine();
366 4 : if (ImGui::SmallButton("Settings")) {
367 0 : show_settings_ = true;
368 0 : }
369 4 : std::string err = engine_.get_last_error();
370 4 : if (!err.empty()) {
371 4 : ImGui::SameLine();
372 4 : ImGui::TextColored(Theme::GoldHot(), " %s", err.c_str());
373 0 : }
374 4 : ImGui::EndChild();
375 4 : ImGui::PopStyleColor();
376 4 : }
377 36 : }
378 :
379 : } // namespace Amplitron
|