diff --git a/automation/tests/cli/cli-test-macro.lua b/automation/tests/cli/cli-test-macro.lua new file mode 100644 index 0000000000..9a3dcc69a5 --- /dev/null +++ b/automation/tests/cli/cli-test-macro.lua @@ -0,0 +1,42 @@ +-- Automation 4 test file +-- Deterministic macro used by the meson test suite to exercise --cli mode + +script_name = "CLI test macros" +script_description = "Macros used by the automated tests for Aegisub's CLI mode" +script_author = "Aegisub Project" +script_version = "1" + +local clipboard = require 'aegisub.clipboard' + +local function append_marker(subs, sel, act) + aegisub.progress.title("CLI test") + aegisub.progress.task("Appending markers") + + -- In CLI mode the clipboard is a process-local stand-in that never + -- touches the system clipboard or the main thread + assert(clipboard.set("cli-test-clipboard"), "clipboard.set failed") + assert(clipboard.get() == "cli-test-clipboard", "clipboard.get returned the wrong value") + + for n, i in ipairs(sel) do + local line = subs[i] + if not line.comment then + line.text = line.text .. "\\N{cli-test}" + subs[i] = line + end + aegisub.progress.set(100 * n / #sel) + end + aegisub.set_undo_point("append cli-test marker") +end + +local function count_lines(subs, sel, act) + local dialogue = 0 + for i = 1, #subs do + if subs[i].class == "dialogue" then + dialogue = dialogue + 1 + end + end + aegisub.log(0, "dialogue lines: " .. dialogue .. "\n") +end + +aegisub.register_macro("CLI Test/Append marker", "Appends a marker to all selected lines", append_marker) +aegisub.register_macro("CLI Test/Count lines", "Logs the number of dialogue lines", count_lines) diff --git a/automation/tests/cli/empty.ass b/automation/tests/cli/empty.ass new file mode 100644 index 0000000000..48d54fab0e --- /dev/null +++ b/automation/tests/cli/empty.ass @@ -0,0 +1,16 @@ +[Script Info] +; Script generated by Aegisub +Title: CLI test empty file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None +PlayResX: 1280 +PlayResY: 720 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text diff --git a/automation/tests/cli/input.ass b/automation/tests/cli/input.ass new file mode 100644 index 0000000000..6ad7a3d69c --- /dev/null +++ b/automation/tests/cli/input.ass @@ -0,0 +1,20 @@ +[Script Info] +; Script generated by Aegisub +Title: CLI test file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None +PlayResX: 1280 +PlayResY: 720 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,,First line +Dialogue: 0,0:00:05.00,0:00:10.00,Default,,0,0,0,,Second line +Comment: 0,0:00:10.00,0:00:15.00,Default,,0,0,0,,A comment line +Dialogue: 0,0:00:15.00,0:00:20.00,Default,,0,0,0,,Third line diff --git a/automation/tests/cli/run_test.py b/automation/tests/cli/run_test.py new file mode 100644 index 0000000000..7d4d44335c --- /dev/null +++ b/automation/tests/cli/run_test.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""End-to-end tests for Aegisub's --cli mode. + +Runs a deterministic automation macro on fixture files in a throwaway HOME +(so the user's real config is untouched) and checks the results, plus the +error paths for bad command lines and unloadable input files. Must work +without a display server; DISPLAY/WAYLAND_DISPLAY are stripped from the +environment to make sure of that. +""" + +import os +import subprocess +import sys +import tempfile + +def run_cli(aegisub, args, env): + proc = subprocess.run([aegisub, "--cli"] + args, env=env, timeout=240, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + sys.stdout.buffer.write(proc.stdout) + return proc + +def fail(message, output=None): + print(message) + if output is not None: + print("---- output file ----") + print(output) + return 1 + +def test_macro_runs(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 0: + return fail(f"aegisub --cli exited with {proc.returncode}") + + if not os.path.exists(out_file): + return fail("output file was not written") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + marked = out.count("\\N{cli-test}") + if marked != 3: + return fail(f"expected 3 marked dialogue lines, found {marked}", out) + + if "A comment line" not in out: + return fail("comment line went missing from the output", out) + return 0 + +def test_empty_file(aegisub, srcdir, env, tmp): + # A valid file with no dialogue lines gets a blank line inserted on + # load, the same as when the GUI opens it, and the macro runs on that + out_file = os.path.join(tmp, "out-empty.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "empty.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 0: + return fail(f"empty input: aegisub --cli exited with {proc.returncode}") + + if not os.path.exists(out_file): + return fail("empty input: output file was not written") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + marked = out.count("\\N{cli-test}") + if marked != 1: + return fail(f"empty input: expected 1 marked line, found {marked}", out) + return 0 + +def test_missing_input(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out-missing.ass") + proc = run_cli(aegisub, [ + os.path.join(tmp, "does-not-exist.ass"), + out_file, + "CLI Test/Append marker", + "--automation", os.path.join(srcdir, "cli-test-macro.lua"), + ], env) + if proc.returncode != 1: + return fail(f"missing input: expected exit code 1, got {proc.returncode}") + if b"Failed to load" not in proc.stdout: + return fail("missing input: no diagnostic was printed") + if os.path.exists(out_file): + return fail("missing input: an output file was written anyway") + return 0 + +def test_resample(aegisub, srcdir, env, tmp): + # The built-in resampler has no UI in CLI mode and instead resamples to + # the video's resolution, which is what muxtools relies on + out_file = os.path.join(tmp, "out-resample.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "tool/resampleres", + "--video", "?dummy:24:48:1920:1080:0:0:0:", + ], env) + if proc.returncode != 0: + return fail(f"resample: aegisub --cli exited with {proc.returncode}") + + with open(out_file, encoding="utf-8-sig") as f: + out = f.read() + + if "PlayResX: 1920" not in out or "PlayResY: 1080" not in out: + return fail("resample: PlayRes was not changed to the video's resolution", out) + # 1280x720 -> 1920x1080 scales sizes by 1.5 + if "Style: Default,Arial,72," not in out: + return fail("resample: the style was not resampled", out) + return 0 + +def test_resample_without_video(aegisub, srcdir, env, tmp): + out_file = os.path.join(tmp, "out-resample-novideo.ass") + proc = run_cli(aegisub, [ + os.path.join(srcdir, "input.ass"), + out_file, + "tool/resampleres", + ], env) + if proc.returncode != 1: + return fail(f"resample without video: expected exit code 1, got {proc.returncode}") + if b"requires --video" not in proc.stdout: + return fail("resample without video: no diagnostic was printed") + if os.path.exists(out_file): + return fail("resample without video: an output file was written anyway") + return 0 + +def test_no_user_state(aegisub, srcdir, env, tmp): + # None of the runs above may have written config, hotkeys, MRU lists, + # logs etc. into the user's config directory + written = [] + for user_dir in (os.path.join(tmp, ".aegisub"), + os.path.join(tmp, "Library", "Application Support", "Aegisub")): + for root, _, files in os.walk(user_dir): + written += [os.path.join(root, f) for f in files] + if written: + return fail("files were written to the user's config directory:\n" + "\n".join(written)) + return 0 + +def test_bad_option_value(aegisub, srcdir, env, tmp): + # A malformed value must produce a clean error, not an uncaught + # exception (which would show up here as death by SIGABRT) + proc = run_cli(aegisub, ["--active-line=notanumber"], env) + if proc.returncode != 1: + return fail(f"bad option value: expected exit code 1, got {proc.returncode}") + return 0 + +def test_unknown_option(aegisub, srcdir, env, tmp): + proc = run_cli(aegisub, ["--definitely-not-an-option"], env) + if proc.returncode != 1: + return fail(f"unknown option: expected exit code 1, got {proc.returncode}") + if b"unrecognised option" not in proc.stdout: + return fail("unknown option: no diagnostic was printed") + return 0 + +def main(): + aegisub, srcdir = sys.argv[1], sys.argv[2] + + env = dict(os.environ) + env.pop("DISPLAY", None) + env.pop("WAYLAND_DISPLAY", None) + + with tempfile.TemporaryDirectory() as tmp: + env["HOME"] = tmp + env["XDG_CONFIG_HOME"] = os.path.join(tmp, ".config") + env["XDG_CACHE_HOME"] = os.path.join(tmp, ".cache") + env["XDG_DATA_HOME"] = os.path.join(tmp, ".local", "share") + # macOS looks up ?user through Foundation, which ignores HOME + env["CFFIXED_USER_HOME"] = tmp + + # ?data points at the install prefix, which doesn't exist for an + # uninstalled build, so point it at the source tree instead to give + # scripts access to the bundled include files (moonscript etc.) + env["AEGISUB_DATA_DIR"] = os.path.dirname(os.path.dirname(os.path.dirname(srcdir))) + + tests = [test_macro_runs, test_empty_file, test_missing_input, + test_resample, test_resample_without_video, + test_bad_option_value, test_unknown_option, + test_no_user_state] + for test in tests: + print(f"== {test.__name__}") + if test(aegisub, srcdir, env, tmp) != 0: + return 1 + + print("ok") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libaegisub/common/hotkey.cpp b/libaegisub/common/hotkey.cpp index c775cdb85f..b2b9f29800 100644 --- a/libaegisub/common/hotkey.cpp +++ b/libaegisub/common/hotkey.cpp @@ -174,6 +174,9 @@ std::string_view Hotkey::GetHotkey(std::string_view context, std::string_view co } void Hotkey::Flush() { + if (config_file.empty()) + return; + json::Object root; auto get = [](json::Object& obj, std::string_view key) -> json::UnknownElement& { diff --git a/libaegisub/common/mru.cpp b/libaegisub/common/mru.cpp index 6029139d4e..253ee9a731 100644 --- a/libaegisub/common/mru.cpp +++ b/libaegisub/common/mru.cpp @@ -68,6 +68,9 @@ MRUManager::MRUManager(agi::fs::path const& config, std::string_view default_con { LOG_D("agi/mru") << "Loading MRU List"; + // An empty config path means the MRU list is in-memory only (CLI mode) + if (config_name.empty()) return; + auto root = json_util::file(config, default_config); for (auto const& it : static_cast(root)) Load(it.first.c_str(), it.second); @@ -114,6 +117,9 @@ agi::fs::path const& MRUManager::GetEntry(std::string_view key, const size_t ent } void MRUManager::Flush() { + if (config_name.string().empty()) + return; + json::Object out; for (size_t i = 0; i < mru.size(); ++i) { diff --git a/libaegisub/common/path.cpp b/libaegisub/common/path.cpp index 8f88b41806..8e20b8450a 100644 --- a/libaegisub/common/path.cpp +++ b/libaegisub/common/path.cpp @@ -20,6 +20,8 @@ #include +#include + namespace { constexpr std::string_view tokens[] = { "?audio", @@ -61,6 +63,15 @@ Path::Path() { static_assert(sizeof(paths) / sizeof(paths[0]) == sizeof(tokens) / sizeof(tokens[0]), "Token and path arrays need to be the same size"); FillPlatformSpecificPaths(); + + // Lets an uninstalled build (e.g. in the test suite) use the bundled + // files from the source tree +#ifdef _WIN32 + if (const wchar_t *data = _wgetenv(L"AEGISUB_DATA_DIR"); data && *data) +#else + if (const char *data = getenv("AEGISUB_DATA_DIR"); data && *data) +#endif + SetToken("?data", fs::path(data)); } fs::path Path::Decode(std::string_view path) const { diff --git a/libaegisub/include/libaegisub/background_runner.h b/libaegisub/include/libaegisub/background_runner.h index 29bd8efcbc..63c6740fe9 100644 --- a/libaegisub/include/libaegisub/background_runner.h +++ b/libaegisub/include/libaegisub/background_runner.h @@ -19,6 +19,7 @@ #pragma once #include +#include #include #include @@ -54,6 +55,23 @@ namespace agi { virtual bool IsCancelled()=0; }; + /// @class CLIProgressSink + /// @brief A progress sink that doesn't do anything with its progress updates except print logs + class CLIProgressSink : public ProgressSink { + std::ostream &stream; + + public: + CLIProgressSink(std::ostream &stream = std::cout) : stream(stream) {}; + + void SetIndeterminate() {} + void SetTitle(std::string const&) {} + void SetMessage(std::string const&) {} + void SetProgress(int64_t, int64_t) {} + void Log(std::string const& str) { stream << str; } + void SetStayOpen(bool) {} + bool IsCancelled() { return false; } + }; + /// @class BackgroundRunner /// @brief A class which runs a function, providing it with a progress sink /// diff --git a/meson.build b/meson.build index ce42591e5a..154e181e1d 100644 --- a/meson.build +++ b/meson.build @@ -198,6 +198,9 @@ endif zlib_dep = dependency('zlib') deps += zlib_dep +# Header-only; the wrap builds CLI11's own first-party meson support +deps += dependency('CLI11', version: '>=2.0') + use_bundled_wx = force_all_fallbacks or force_fallback_for.contains('wxWidgets') if not use_bundled_wx wx_dep = dependency('wxWidgets', version: '>=' + get_option('wx_version'), @@ -594,3 +597,20 @@ aegisub = executable('aegisub', aegisub_src, version_h, libresrc_headers, aegisu install_dir: bindir, dependencies: deps, win_subsystem: 'windows') + +if get_option('tests') and host_machine.system() == 'linux' + # End-to-end test of --cli mode: runs an automation macro on a fixture + # file without a display server and checks the output. Linux-only for + # now: Windows keeps wx's entry point so there is no --cli there, and + # the macOS CI builds have not been vetted for running outside a bundle. + test('automation-cli', + find_program('python3', 'python'), + args: [ + meson.current_source_dir() / 'automation' / 'tests' / 'cli' / 'run_test.py', + aegisub.full_path(), + meson.current_source_dir() / 'automation' / 'tests' / 'cli', + ], + suite: 'automation', + depends: aegisub, + timeout: 300) +endif diff --git a/src/async_video_provider.cpp b/src/async_video_provider.cpp index 2ffee2abd9..ea97efbe59 100644 --- a/src/async_video_provider.cpp +++ b/src/async_video_provider.cpp @@ -19,6 +19,7 @@ #include "ass_dialogue.h" #include "ass_file.h" #include "export_fixstyle.h" +#include "options.h" #include "include/aegisub/subtitles_provider.h" #include "video_frame.h" #include "video_provider_manager.h" @@ -240,6 +241,10 @@ bool AsyncVideoProvider::NeedUpdate(std::vector const& v } void AsyncVideoProvider::ProcAsync(uint_fast32_t req_version, bool check_updated) { + // There is no event loop to deliver the rendered frame to in CLI mode, + // and queueing wx events without a wxApp crashes + if (!config::hasGui) return; + // Only actually produce the frame if there's no queued changes waiting if (req_version < version || frame_number < 0) return; diff --git a/src/audio_controller.cpp b/src/audio_controller.cpp index 12ec3aa99b..3381291b3b 100644 --- a/src/audio_controller.cpp +++ b/src/audio_controller.cpp @@ -41,10 +41,12 @@ AudioController::AudioController(agi::Context *context) : context(context) -, playback_timer(this) , provider_connection(context->project->AddAudioProviderListener(&AudioController::OnAudioProvider, this)) { - Bind(wxEVT_TIMER, &AudioController::OnPlaybackTimer, this, playback_timer.GetId()); + if (config::hasGui) { + playback_timer = std::make_unique(this); + Bind(wxEVT_TIMER, &AudioController::OnPlaybackTimer, this, playback_timer->GetId()); + } #ifdef wxHAS_POWER_EVENTS Bind(wxEVT_POWER_SUSPENDED, &AudioController::OnComputerSuspending, this); @@ -140,7 +142,7 @@ void AudioController::PlayRange(const TimeRange &range) player->Play(SamplesFromMilliseconds(range.begin()), SamplesFromMilliseconds(range.length())); playback_mode = PM_Range; - playback_timer.Start(20); + playback_timer->Start(20); AnnouncePlaybackPosition(range.begin()); } @@ -166,7 +168,7 @@ void AudioController::PlayToEnd(int start_ms) int64_t start_sample = SamplesFromMilliseconds(start_ms); player->Play(start_sample, provider->GetNumSamples()-start_sample); playback_mode = PM_ToEnd; - playback_timer.Start(20); + playback_timer->Start(20); AnnouncePlaybackPosition(start_ms); } @@ -177,7 +179,7 @@ void AudioController::Stop() player->Stop(); playback_mode = PM_NotPlaying; - playback_timer.Stop(); + playback_timer->Stop(); AnnouncePlaybackStop(); } diff --git a/src/audio_controller.h b/src/audio_controller.h index c61c7aa61f..e09941a34d 100644 --- a/src/audio_controller.h +++ b/src/audio_controller.h @@ -85,7 +85,7 @@ class AudioController final : public wxEvtHandler, private agi::signal::Connecti PlaybackMode playback_mode = PM_NotPlaying; /// Timer used for playback position updates - wxTimer playback_timer; + std::unique_ptr playback_timer; /// The audio provider agi::AudioProvider *provider = nullptr; diff --git a/src/auto4_base.cpp b/src/auto4_base.cpp index 74254bb55a..b60ddfc1ab 100644 --- a/src/auto4_base.cpp +++ b/src/auto4_base.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +51,7 @@ #include #include +#include #include #include #include @@ -121,6 +123,15 @@ namespace Automation4 { DeleteObject(dc); #else // not WIN32 + if (!config::hasInitializedWx) { + LOG_W("agi") << "Text extents were requested. Initializing wxWidgets. This requires a display server."; + if (!wxInitialize()) { + LOG_E("agi") << "Initializing wxWidgets failed (no display server?); text extents are unavailable."; + return false; + } + config::hasInitializedWx = true; + } + wxMemoryDC thedc; // fix fontsize to be 72 DPI @@ -224,7 +235,7 @@ namespace Automation4 { } BackgroundScriptRunner::BackgroundScriptRunner(wxWindow *parent, std::string const& title) - : impl(new DialogProgress(parent, to_wx(title))) + : impl(new OptDialogProgress(parent, to_wx(title))) { } @@ -242,12 +253,12 @@ namespace Automation4 { wxWindow *BackgroundScriptRunner::GetParentWindow() const { - return impl.get(); + return impl->getImpl(); } std::string BackgroundScriptRunner::GetTitle() const { - return from_wx(impl->GetTitle()); + return from_wx(impl->getImpl()->GetTitle()); } // Script diff --git a/src/auto4_base.h b/src/auto4_base.h index eeea820506..f3a60e704d 100644 --- a/src/auto4_base.h +++ b/src/auto4_base.h @@ -40,7 +40,7 @@ #include class AssStyle; -class DialogProgress; +class OptDialogProgress; class wxWindow; class wxDialog; @@ -97,7 +97,7 @@ namespace Automation4 { class ProgressSink; class BackgroundScriptRunner { - std::unique_ptr impl; + std::unique_ptr impl; public: wxWindow *GetParentWindow() const; diff --git a/src/auto4_lua.cpp b/src/auto4_lua.cpp index 72bd280c03..2ca4365c35 100644 --- a/src/auto4_lua.cpp +++ b/src/auto4_lua.cpp @@ -137,10 +137,17 @@ namespace { return 1; } + // In CLI mode there is no system clipboard to talk to (and doing so would + // need a display server), so scripts get a process-local clipboard + std::string cli_clipboard; + char *clipboard_get() { std::string data; - agi::dispatch::Main().Sync([&] { data = GetClipboard(); }); + if (config::hasGui) + agi::dispatch::Main().Sync([&] { data = GetClipboard(); }); + else + data = cli_clipboard; if (data.empty()) return nullptr; return strndup(data); @@ -148,6 +155,11 @@ namespace { bool clipboard_set(const char *str) { + if (!config::hasGui) { + cli_clipboard = str; + return true; + } + bool succeeded = false; agi::dispatch::Main().Sync([&] { @@ -890,6 +902,11 @@ namespace { catch (agi::UserCancelException const&) { subsobj->Cancel(); stackcheck.check_stack(0); + // In CLI mode there is no user to cancel the run, so this either + // means the script threw an error or a dialog went unanswered; + // propagate it so the process can exit with an error + if (!config::hasGui) + throw; return; } diff --git a/src/auto4_lua.h b/src/auto4_lua.h index 00b78310a3..8bb3680ff5 100644 --- a/src/auto4_lua.h +++ b/src/auto4_lua.h @@ -219,6 +219,10 @@ namespace Automation4 { /// in a single table int LuaReadBack(lua_State *L); + /// Simulates the given button being pressed. Useful when the dialog + /// isn't actually being shown, like in CLI mode. + void PushButton(int button); + // ScriptDialog implementation wxWindow* CreateWindow(wxWindow *parent) override; std::string Serialise() override; diff --git a/src/auto4_lua_dialog.cpp b/src/auto4_lua_dialog.cpp index 4bbeb651b0..4ae520ce88 100644 --- a/src/auto4_lua_dialog.cpp +++ b/src/auto4_lua_dialog.cpp @@ -36,6 +36,7 @@ #include "colour_button.h" #include "compat.h" +#include "options.h" #include "validators.h" #include @@ -453,6 +454,15 @@ namespace Automation4 { btn->first = id; }); } + + if (buttons.empty()) { + buttons.emplace_back(wxID_OK, ""); + buttons.emplace_back(wxID_CANCEL, ""); + } + + for (int i = 0; i < std::ssize(buttons); i++) { + LOG_D("automation/lua/dialog") << "created button: " << buttons[i].second << " (" << i << ")"; + } } wxWindow* LuaDialog::CreateWindow(wxWindow *parent) { @@ -484,11 +494,6 @@ namespace Automation4 { return window; } - if (buttons.size() == 0) { - buttons.emplace_back(wxID_OK, ""); - buttons.emplace_back(wxID_CANCEL, ""); - } - auto dialog = static_cast(parent); auto bs = new wxStdDialogButtonSizer; @@ -532,10 +537,14 @@ namespace Automation4 { int LuaDialog::LuaReadBack(lua_State *L) { // First read back which button was pressed, if any if (use_buttons) { - if (button_pushed == -1 || buttons[button_pushed].first == wxID_CANCEL) + if (button_pushed == -1 || buttons[button_pushed].first == wxID_CANCEL) { + LOG_I("automation/lua/dialog") << "Pushing cancel"; lua_pushboolean(L, false); - else - lua_pushstring(L, buttons[button_pushed].second.c_str()); + } else { + const char* s = buttons[button_pushed].second.c_str(); + LOG_I("automation/lua/dialog") << "Pushing " << s; + lua_pushstring(L, s); + } } // Then read controls back @@ -548,6 +557,15 @@ namespace Automation4 { return use_buttons ? 2 : 1; } + void LuaDialog::PushButton(int button) { + if (button != -1 && (button < 0 || button >= std::ssize(buttons))) { + LOG_E("agi/auto4_lua_dialog") << "Button " << button << " not in range; defaulting to cancel"; + button = -1; + } + + button_pushed = button; + } + std::string LuaDialog::Serialise() { std::string res; diff --git a/src/auto4_lua_progresssink.cpp b/src/auto4_lua_progresssink.cpp index 8569121311..a923d5f434 100644 --- a/src/auto4_lua_progresssink.cpp +++ b/src/auto4_lua_progresssink.cpp @@ -35,8 +35,10 @@ #include "auto4_lua.h" #include "compat.h" +#include "options.h" #include +#include #include #include @@ -190,7 +192,15 @@ namespace Automation4 { ProgressSink *ps = GetObjPointer(L, lua_upvalueindex(1)); LuaDialog dlg(L, true); // magically creates the config dialog structure etc - ps->ShowDialog(&dlg); + + if (config::hasGui) { + ps->ShowDialog(&dlg); + } else if (config::dialog_responses.size() > 0) { + auto& pair = config::dialog_responses.front(); + dlg.PushButton(pair.first); + dlg.Unserialise(pair.second); + config::dialog_responses.pop_front(); + } // more magic: puts two values on stack: button pushed and table with control results return dlg.LuaReadBack(L); @@ -211,23 +221,44 @@ namespace Automation4 { if (must_exist) flags |= wxFD_FILE_MUST_EXIST; - agi::dispatch::Main().Sync([&] { - wxFileDialog diag(nullptr, message, dir, file, wildcard, flags); - if (diag.ShowModal() == wxID_CANCEL) { - lua_pushnil(L); - } else if (multiple) { - wxArrayString files; - diag.GetPaths(files); - - lua_createtable(L, files.size(), 0); - for (size_t i = 0; i < files.size(); ++i) { - lua_pushstring(L, files[i].utf8_str()); + if (config::hasGui) { + agi::dispatch::Main().Sync([&] { + wxFileDialog diag(nullptr, message, dir, file, wildcard, flags); + if (diag.ShowModal() == wxID_CANCEL) { + lua_pushnil(L); + } else if (multiple) { + wxArrayString files; + diag.GetPaths(files); + + lua_createtable(L, files.size(), 0); + for (size_t i = 0; i < files.size(); ++i) { + lua_pushstring(L, files[i].utf8_str()); + lua_rawseti(L, -2, i + 1); + } + } else { + lua_pushstring(L, diag.GetPath().utf8_str()); + } + }); + } else if (!config::file_responses.empty()) { + auto& paths = config::file_responses.front(); + + if (multiple) { + lua_createtable(L, paths.size(), 0); + for (size_t i = 0; i < paths.size(); ++i) { + LOG_I("automation/lua/dialog") << "Opening " << paths[i]; + lua_pushstring(L, paths[i].string().c_str()); lua_rawseti(L, -2, i + 1); } } else { - lua_pushstring(L, diag.GetPath().utf8_str()); + LOG_I("automation/lua/dialog") << "Opening " << paths[0]; + lua_pushstring(L, paths[0].string().c_str()); } - }); + + config::file_responses.pop_front(); + } else { + LOG_I("automation/lua/dialog") << "Canceling open dialog"; + lua_pushnil(L); + } return 1; } @@ -245,14 +276,24 @@ namespace Automation4 { if (prompt_overwrite) flags |= wxFD_OVERWRITE_PROMPT; - agi::dispatch::Main().Sync([&] { - wxFileDialog diag(ps->GetParentWindow(), message, dir, file, wildcard, flags); - if (diag.ShowModal() == wxID_CANCEL) { - lua_pushnil(L); - } else { - lua_pushstring(L, diag.GetPath().utf8_str()); - } - }); + if (config::hasGui) { + agi::dispatch::Main().Sync([&] { + wxFileDialog diag(ps->GetParentWindow(), message, dir, file, wildcard, flags); + if (diag.ShowModal() == wxID_CANCEL) { + lua_pushnil(L); + } else { + lua_pushstring(L, diag.GetPath().utf8_str()); + } + }); + } else if (!config::file_responses.empty()){ + auto& paths = config::file_responses.front(); + LOG_I("automation/lua/dialog") << "Saving to " << paths[0]; + lua_pushstring(L, paths[0].string().c_str()); + config::file_responses.pop_front(); + } else { + LOG_I("automation/lua/dialog") << "Canceling save dialog"; + lua_pushnil(L); + } return 1; } diff --git a/src/charset_detect.cpp b/src/charset_detect.cpp index 459fdac81c..108886f87d 100644 --- a/src/charset_detect.cpp +++ b/src/charset_detect.cpp @@ -30,6 +30,7 @@ #include "charset_detect.h" #include "compat.h" +#include "gui_wrap.h" #include #include @@ -46,7 +47,8 @@ std::string GetEncoding(agi::fs::path const& filename) { return encoding; auto choices = agi::charset::GetEncodingsList(); - int choice = wxGetSingleChoiceIndex( + int choice = wrapChoiceDialog( + "subtitle-charset", _("Aegisub could not narrow down the character set to a single one.\nPlease pick one below:"), _("Choose character set"), choices); diff --git a/src/cli.cpp b/src/cli.cpp new file mode 100644 index 0000000000..736003f097 --- /dev/null +++ b/src/cli.cpp @@ -0,0 +1,249 @@ +// Copyright (c) 2020, Myaamori +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +#include "cli.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +std::set parse_range(const std::string& s) { + std::set lines; + for (auto tok : agi::Split(s, ',')) { + auto item = std::string(tok); + int i; + + if (agi::util::try_parse(item, &i)) { + lines.insert(i); + } else { + auto sep = item.find('-'); + if (sep == std::string::npos) { + throw agi::InvalidInputException("Invalid number or range: " + item); + } + + if (!agi::util::try_parse(item.substr(0, sep), &i)) { + throw agi::InvalidInputException("Invalid start number in range: " + item); + } + + int j; + if (!agi::util::try_parse(item.substr(sep + 1), &j)) { + throw agi::InvalidInputException("Invalid end number in range: " + item); + } + + for (int k = i; k <= j; k++) { + lines.insert(k); + } + } + } + return lines; +} + +std::string serialize_element(const json::UnknownElement& elem) { + try { + auto& val_str = static_cast(elem); + return agi::ass::inline_string_encode(val_str); + } + catch (json::Exception const& e) {} + + try { + auto& val_int = static_cast(elem); + return std::to_string(val_int); + } + catch (json::Exception const& e) {} + + try { + auto& val_double = static_cast(elem); + return std::to_string(val_double); + } + catch (json::Exception const& e) {} + + try { + auto& val_bool = static_cast(elem); + return val_bool ? "1" : "0"; + } catch (json::Exception const& e) {} + + auto& val_array = static_cast(elem); + if (val_array.size() != 3 && val_array.size() != 4) { + LOG_E("main/cli") << "Wrong array length for color"; + throw json::Exception("Wrong array length for color"); + } + + agi::Color color; + for (int i = 0; i < val_array.size(); i++) { + int arrelem_int = static_cast(val_array[i]); + if (arrelem_int < 0 || arrelem_int >= 256) { + LOG_E("main/cli") << "Color part not within [0,255] bounds"; + throw json::Exception("Color part not within [0,255] bounds"); + } + + switch (i) { + case 0: color.r = arrelem_int; break; + case 1: color.g = arrelem_int; break; + case 2: color.b = arrelem_int; break; + case 3: color.a = arrelem_int; break; + } + } + return val_array.size() == 4 ? color.GetAssStyleFormatted() : color.GetAssOverrideFormatted(); +} + +std::pair json_to_lua(const std::string& s) { + std::istringstream stream(s); + auto elem = agi::json_util::parse(stream); + auto& root = static_cast(elem); + auto button = root.find("button"); + if (button == root.end()) { + throw agi::InvalidInputException("No button specified in JSON: " + s); + } + + const json::Integer* button_num = nullptr; + try { + button_num = &static_cast(button->second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException("'button' not an integer in JSON: " + s); + } + + auto values = root.find("values"); + if (values == root.end()) { + return std::make_pair(*button_num, ""); + } + + const json::Object* values_obj = nullptr; + try { + values_obj = &static_cast(values->second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException("'values' not an object in JSON: " + s); + } + + std::string vals = ""; + for (auto& p : *values_obj) { + if (!vals.empty()) { + vals += "|"; + } + + auto& key = p.first; + try { + vals += agi::ass::inline_string_encode(key) + ":" + serialize_element(p.second); + } + catch (json::Exception const& e) { + throw agi::InvalidInputException(agi::format("Could not serialize 'values.%s' in JSON: %s", key, s)); + } + } + + return std::make_pair(*button_num, vals); +} + +std::list> parse_dialog_responses(const std::vector& args) { + std::list> responses; + for (auto& s : args) { + auto pair = json_to_lua(s); + LOG_D("main/cli") << agi::format("Dialog response: button %d -> %s", pair.first, pair.second); + responses.push_back(std::move(pair)); + } + return responses; +} + +std::list> parse_file_responses(const std::vector& args) { + std::list> responses; + for (auto& s : args) { + std::vector paths; + std::stringstream ss; + for (const auto& tok : agi::Split(s, '|')) { + agi::fs::path p(std::filesystem::absolute(std::string(tok))); + ss << p << " "; + paths.push_back(std::move(p)); + } + LOG_E("main/cli") << "File response: " << ss.str(); + responses.push_back(std::move(paths)); + } + return responses; +} + +namespace { +std::deque main_queue; +std::mutex main_queue_mutex; +std::condition_variable main_queue_cv; +} + +namespace cli { +void InitDispatch() { + agi::dispatch::Init([](agi::dispatch::Thunk f) { + { + std::lock_guard lock(main_queue_mutex); + main_queue.push_back(std::move(f)); + } + main_queue_cv.notify_all(); + }); +} + +void RunWithMainLoop(std::function work) { + bool done = false; + std::exception_ptr err; + + std::thread worker([&] { + try { + work(); + } catch (...) { + err = std::current_exception(); + } + { + std::lock_guard lock(main_queue_mutex); + done = true; + } + main_queue_cv.notify_all(); + }); + + std::unique_lock lock(main_queue_mutex); + for (;;) { + main_queue_cv.wait(lock, [&] { return done || !main_queue.empty(); }); + if (main_queue.empty()) { + if (done) break; + continue; + } + auto f = std::move(main_queue.front()); + main_queue.pop_front(); + lock.unlock(); + try { + f(); + } catch (agi::Exception const& e) { + LOG_E("main/cli") << "Unhandled exception on the main thread: " << e.GetMessage(); + } catch (std::exception const& e) { + LOG_E("main/cli") << "Unhandled exception on the main thread: " << e.what(); + } + lock.lock(); + } + lock.unlock(); + + worker.join(); + if (err) std::rethrow_exception(err); +} +} diff --git a/src/cli.h b/src/cli.h new file mode 100644 index 0000000000..ba5648b90b --- /dev/null +++ b/src/cli.h @@ -0,0 +1,52 @@ +// Copyright (c) 2020, Myaamori +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +/// @file cli.h +/// @brief Parser and helper functions for Aegisub's CLI interface + +#include + +#include +#include +#include +#include +#include + +#include + +std::set parse_range(const std::string& s); + +std::list> parse_dialog_responses(const std::vector& s); + +std::list> parse_file_responses(const std::vector& s); + +namespace cli { + /// Install an agi::dispatch main-thread executor which queues up thunks + /// to be run by RunWithMainLoop. Must be called from the main thread + /// before anything uses agi::dispatch. + void InitDispatch(); + + /// Run work on a background thread while executing queued main-thread + /// thunks on the calling thread until the work completes, mirroring how + /// the GUI runs automation scripts on a worker thread while the main + /// thread pumps events. Without this, anything which does + /// agi::dispatch::Main().Sync() from the work function (e.g. text + /// extents) would deadlock in CLI mode. + /// + /// Exceptions thrown by the work function are rethrown on this thread. + void RunWithMainLoop(std::function work); +} diff --git a/src/command/tool.cpp b/src/command/tool.cpp index 21517de8ff..9f5cf467df 100644 --- a/src/command/tool.cpp +++ b/src/command/tool.cpp @@ -38,9 +38,12 @@ #include "../dialogs.h" #include "../include/aegisub/context.h" #include "../libresrc/libresrc.h" +#include "../options.h" +#include "../project.h" #include "../resolution_resampler.h" #include "../video_controller.h" +#include #include #include @@ -97,8 +100,16 @@ struct tool_resampleres final : public Command { void operator()(agi::Context *c) override { c->videoController->Stop(); ResampleSettings settings; - if (PromptForResampleSettings(c, settings)) - ResampleResolution(c->ass.get(), settings); + if (!config::hasGui) { + // There's no one to ask in CLI mode, so do what the dialog + // suggests, as the standalone aegisub-cli did + if (!c->project->VideoProvider()) + throw agi::InvalidInputException("Resampling in CLI mode requires --video"); + settings = DefaultResampleSettings(c); + } + else if (!PromptForResampleSettings(c, settings)) + return; + ResampleResolution(c->ass.get(), settings); } }; diff --git a/src/dialog_progress.cpp b/src/dialog_progress.cpp index 314c79ec34..62e6dd5349 100644 --- a/src/dialog_progress.cpp +++ b/src/dialog_progress.cpp @@ -19,7 +19,9 @@ #include "dialog_progress.h" +#include "cli.h" #include "compat.h" +#include "options.h" #include "utils.h" #include @@ -256,3 +258,27 @@ void DialogProgress::SetProgress(int target) { progress_anim_start_time = now; progress_target = target; } + +OptDialogProgress::OptDialogProgress(wxWindow *parent, wxString const& title, wxString const& message) +: impl(config::hasGui ? new DialogProgress(parent, title, message) : nullptr) +{ } + +void OptDialogProgress::Run(std::function task) { + if (impl) { + impl->Run(task); + return; + } + + // Mirror the GUI threading model: the task runs on a worker thread while + // this thread pumps the main-thread dispatch queue, so that the task can + // use agi::dispatch::Main().Sync() without deadlocking + agi::CLIProgressSink ps; + cli::RunWithMainLoop([&] { + try { + task(&ps); + } + catch (agi::Exception const& e) { + ps.Log(e.GetMessage()); + } + }); +} diff --git a/src/dialog_progress.h b/src/dialog_progress.h index 492cdbcd61..7f6b827db8 100644 --- a/src/dialog_progress.h +++ b/src/dialog_progress.h @@ -68,3 +68,17 @@ class DialogProgress final : public wxDialog, public agi::BackgroundRunner { /// BackgroundWorker implementation void Run(std::function task) override; }; + +/// @class OptDialogProgress +/// @brief Background runner that uses DialogProgress in gui mode and runs the task directly otherwise +class OptDialogProgress : public agi::BackgroundRunner { + std::unique_ptr impl; + +public: + OptDialogProgress(wxWindow *parent, wxString const& title="", wxString const& message=""); + + DialogProgress *getImpl() { return impl.get(); }; + + /// BackgroundWorker implementation + void Run(std::function task) override; +}; diff --git a/src/dialog_resample.cpp b/src/dialog_resample.cpp index 8ef652047c..bd52565804 100644 --- a/src/dialog_resample.cpp +++ b/src/dialog_resample.cpp @@ -17,6 +17,7 @@ #include "ass_file.h" #include "async_video_provider.h" #include "compat.h" +#include "dialogs.h" #include "help_button.h" #include "include/aegisub/context.h" #include "libresrc/libresrc.h" @@ -118,19 +119,15 @@ DialogResample::DialogResample(agi::Context *c, ResampleSettings &settings) { d.SetIcons(GETICONS(resample_toolbutton)); - c->ass->GetResolution(script_w, script_h); - settings.source_x = script_w; - settings.source_y = script_h; + settings = DefaultResampleSettings(c); + script_w = settings.source_x; + script_h = settings.source_y; if (auto provider = c->project->VideoProvider()) { - settings.dest_x = video_w = provider->GetWidth(); - settings.dest_y = video_h = provider->GetHeight(); + video_w = provider->GetWidth(); + video_h = provider->GetHeight(); video_mat = provider->GetRealColorSpace(); } - else { - settings.dest_x = script_w; - settings.dest_y = script_h; - } // Create static box sizers auto source_res_box_sizer = new wxStaticBoxSizer(wxVERTICAL, &d, _("Source Resolution")); @@ -242,23 +239,9 @@ DialogResample::DialogResample(agi::Context *c, ResampleSettings &settings) source_matrix->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { OnMatrixChange(); }); dest_matrix->Bind(wxEVT_COMBOBOX, [this](wxCommandEvent&) { OnMatrixChange(); }); - // Find out if we want to suggest resampling colors. - // If the script matrix is None, we do not know what matrix to resample from, so we don't suggest to resample. - // If the video matrix is unspecified or does not correspond to a valid YCbCr Header value, we cannot currently resample to it. - // -> FIXME: The latter is not a theoretical reason but only due to the fact that resampling is not currently implemented for any other matrices in ycbcr_conv.h: - // We could in theory have a situation where a script with no YCbCr Header (so effectively TV.601) was authored on a BT.2020 video using color matching, - // and the user now wants to adjust the colors so that they can set the matrix to None instead. - // Otherwise, i.e. if e.g. the script has a TV.601 header but the video uses TV.709, we suggest to resample. - // (The assumption here being that, just like when resampling PlayRes, the user only uses the resample dialog when the script *currently* looks correct on the current video.) - agi::ycbcr::Header video_mat_header(video_mat); - if (video_mat_header != script_mat - && video_mat_header.valid() //< (Not necessary in theory, see FIXME above) - && script_mat.valid() //< (Should always be true since we use to_effective() above) - && std::holds_alternative(video_mat_header) - && std::holds_alternative(script_mat) - ) { + if (settings.matrix_conversion) { source_matrix->SetSelection(MatrixOptionFromHeader(script_mat)); - dest_matrix->SetSelection(MatrixOptionFromHeader(video_mat_header)); + dest_matrix->SetSelection(MatrixOptionFromHeader(agi::ycbcr::Header(video_mat))); } OnMatrixChange(); } @@ -323,6 +306,45 @@ void DialogResample::OnMarginChange(wxSpinCtrl *src, wxSpinCtrl *dst) { } } +ResampleSettings DefaultResampleSettings(agi::Context *c) { + ResampleSettings settings; + c->ass->GetResolution(settings.source_x, settings.source_y); + + auto provider = c->project->VideoProvider(); + if (!provider) { + settings.dest_x = settings.source_x; + settings.dest_y = settings.source_y; + return settings; + } + + settings.dest_x = provider->GetWidth(); + settings.dest_y = provider->GetHeight(); + + // Find out if we want to suggest resampling colors. + // If the script matrix is None, we do not know what matrix to resample from, so we don't suggest to resample. + // If the video matrix is unspecified or does not correspond to a valid YCbCr Header value, we cannot currently resample to it. + // -> FIXME: The latter is not a theoretical reason but only due to the fact that resampling is not currently implemented for any other matrices in ycbcr_conv.h: + // We could in theory have a situation where a script with no YCbCr Header (so effectively TV.601) was authored on a BT.2020 video using color matching, + // and the user now wants to adjust the colors so that they can set the matrix to None instead. + // Otherwise, i.e. if e.g. the script has a TV.601 header but the video uses TV.709, we suggest to resample. + // (The assumption here being that, just like when resampling PlayRes, the user only uses the resample dialog when the script *currently* looks correct on the current video.) + agi::ycbcr::Header script_mat = agi::ycbcr::Header(std::string(c->ass->GetScriptInfo("YCbCr Matrix"))).to_effective(); + agi::ycbcr::Header video_mat(provider->GetRealColorSpace()); + if (video_mat != script_mat + && video_mat.valid() //< (Not necessary in theory, see FIXME above) + && script_mat.valid() //< (Should always be true since we use to_effective() above) + && std::holds_alternative(video_mat) + && std::holds_alternative(script_mat) + // Only matrices the dialog offers can be resampled + && MatrixOptionFromHeader(video_mat) != 0 + && MatrixOptionFromHeader(script_mat) != 0 + ) { + settings.matrix_conversion = std::make_pair(std::get(script_mat), std::get(video_mat)); + } + + return settings; +} + bool PromptForResampleSettings(agi::Context *c, ResampleSettings &settings) { return DialogResample(c, settings).d.ShowModal() == wxID_OK; } diff --git a/src/dialog_video_properties.cpp b/src/dialog_video_properties.cpp index 8b0766f98a..167479afb6 100644 --- a/src/dialog_video_properties.cpp +++ b/src/dialog_video_properties.cpp @@ -513,6 +513,10 @@ bool update_layout_res(AssFile *file, const AsyncVideoProvider *new_provider, wx } void UpdateVideoProperties(AssFile *file, const AsyncVideoProvider *new_provider, wxWindow *parent) { + // Everything here prompts the user, which can't be done in CLI mode + if (!config::hasGui) + return; + if (update_ycbcr_matrix(file, new_provider, parent)) file->Commit(_("change ycbcr matrix"), AssFile::COMMIT_SCRIPTINFO); diff --git a/src/dialogs.h b/src/dialogs.h index 019f8f0264..b8c9221841 100644 --- a/src/dialogs.h +++ b/src/dialogs.h @@ -48,6 +48,10 @@ std::string PickAutosaveFile(wxWindow *parent); /// new version actually exists. void PerformVersionCheck(bool interactive); +/// The resampling settings suggested by default: from the script's resolution +/// to the video's (if any), converting colors if the matrices differ +ResampleSettings DefaultResampleSettings(agi::Context *c); + /// Ask the user to pick settings for a script resampling /// @return Does the user want to proceed with the resampling? bool PromptForResampleSettings(agi::Context *c, ResampleSettings &settings); diff --git a/src/ffmpegsource_common.cpp b/src/ffmpegsource_common.cpp index 0d86a07d53..eef1094c32 100644 --- a/src/ffmpegsource_common.cpp +++ b/src/ffmpegsource_common.cpp @@ -37,6 +37,7 @@ #include "compat.h" #include "format.h" +#include "gui_wrap.h" #include "options.h" #include "utils.h" @@ -48,7 +49,6 @@ #include #include #include -#include FFmpegSourceProvider::FFmpegSourceProvider(agi::BackgroundRunner *br) : br(br) @@ -152,7 +152,8 @@ FFmpegSourceProvider::AskForTrackSelection(const std::map &Tra TrackNumbers.push_back(track.first); } - int Choice = wxGetSingleChoiceIndex( + int Choice = wrapChoiceDialog( + Type == FFMS_TYPE_VIDEO ? "video-track" : "audio-track", Type == FFMS_TYPE_VIDEO ? _("Multiple video tracks detected, please choose the one you wish to load:") : _("Multiple audio tracks detected, please choose the one you wish to load:"), Type == FFMS_TYPE_VIDEO ? _("Choose video track") : _("Choose audio track"), Choices); diff --git a/src/gui_wrap.cpp b/src/gui_wrap.cpp new file mode 100644 index 0000000000..f55a8836bb --- /dev/null +++ b/src/gui_wrap.cpp @@ -0,0 +1,74 @@ +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +#include "gui_wrap.h" + +#include "libaegisub/log.h" +#include "libaegisub/format.h" +#include "libaegisub/util.h" + +#include "compat.h" +#include "options.h" + +#include + +#include +#include // Keep this last so wxUSE_CHOICEDLG is set. + + +int wrapMessageBox(const wxString &message, const wxString &caption, long style, wxWindow *parent) { + if (config::hasGui) + return wxMessageBox(message, caption, style, parent); + + agi::log::Severity severity = agi::log::Info; + if (style & wxICON_WARNING) + severity = agi::log::Warning; + if (style & wxICON_ERROR) + severity = agi::log::Exception; + + // The log sink isn't visible on the console, so errors and warnings + // should also reach the user of the CLI directly + if (severity != agi::log::Info) + std::cerr << caption.utf8_str() << ": " << message.utf8_str() << std::endl; + + LOG_SINK("agi", severity) << caption << ": " << message; + return 0; +} + +int wrapChoiceDialog(std::string const& key, const wxString& message, const wxString& caption, const wxArrayString& choices, wxWindow *parent) { + if (config::hasGui) + return wxGetSingleChoiceIndex(message, caption, choices, parent); + + if (!config::choice_indices.count(key) || config::choice_indices[key].empty()) { + LOG_W("agi") << agi::format("No answer given for choice \"%s\". Using first choice \"%s\".", from_wx(caption), from_wx(choices[0])); + return 0; + } + + std::string choice = *config::choice_indices[key].rbegin(); + config::choice_indices[key].pop_back(); + + auto index = std::find(choices.begin(), choices.end(), to_wx(choice)); + if (index != choices.end()) + return std::distance(choices.begin(), index); + + int result; + if (!agi::util::try_parse(choice, &result)) { + LOG_W("agi") << agi::format("Invalid answer \"%s\" given for choice \"%s\". Using first choice \"%s\".", choice, from_wx(caption), from_wx(choices[0])); + return 0; + } + return result; +} + diff --git a/src/gui_wrap.h b/src/gui_wrap.h new file mode 100644 index 0000000000..db6751d531 --- /dev/null +++ b/src/gui_wrap.h @@ -0,0 +1,26 @@ +// Copyright (c) 2023, arch1t3cht +// +// Permission to use, copy, modify, and distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +// +// Aegisub Project http://www.aegisub.org/ + +/// @file gui_wrap.h +/// @brief Wrappers around some common wx dialogs that makes them not show in CLI mode + +#include +#include + +int wrapMessageBox(const wxString &message, const wxString &caption, long style=wxOK | wxCENTRE, wxWindow *parent=nullptr); + +/// Wraps wxGetSingleChoiceIndex. The first argument is the key under which a choice should be searched for in the global config. +int wrapChoiceDialog(std::string const& key, const wxString& message, const wxString& caption, const wxArrayString& choices, wxWindow *parent=nullptr); diff --git a/src/hotkey.cpp b/src/hotkey.cpp index 9f8ec05129..2d3c6f640f 100644 --- a/src/hotkey.cpp +++ b/src/hotkey.cpp @@ -77,8 +77,10 @@ namespace hotkey { agi::hotkey::Hotkey *inst = nullptr; void init() { + // Hotkeys are irrelevant in CLI mode, so don't touch the user's hotkey + // file there; an empty path means the defaults are used and never saved inst = new agi::hotkey::Hotkey( - config::path->Decode("?user/hotkey.json"), + config::hasGui ? config::path->Decode("?user/hotkey.json") : agi::fs::path(), GET_DEFAULT_CONFIG(default_hotkey)); auto migrations = OPT_GET("App/Hotkey Migrations")->GetListString(); diff --git a/src/main.cpp b/src/main.cpp index ec8c2f9e74..d0c4401726 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -37,8 +37,11 @@ #include "command/command.h" #include "include/aegisub/hotkey.h" +#include "ass_dialogue.h" +#include "ass_file.h" #include "auto4_base.h" #include "auto4_lua_factory.h" +#include "cli.h" #include "compat.h" #include "crash_writer.h" #include "dialogs.h" @@ -50,6 +53,7 @@ #include "libresrc/libresrc.h" #include "options.h" #include "project.h" +#include "selection_controller.h" #include "subs_controller.h" #include "subtitles_provider_libass.h" #include "utils.h" @@ -63,9 +67,12 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -76,9 +83,25 @@ namespace config { agi::MRUManager *mru = nullptr; agi::Path *path = nullptr; Automation4::AutoloadScriptManager *global_scripts; + + // These default to the GUI behavior so that platforms which use wx's + // entry point (and thus never run our main()) work unchanged; CLI mode + // overrides them before initialization + bool hasGui = true; + bool hasInitializedWx = true; + bool loadGlobalAutomation = true; + std::map> choice_indices; + std::list> dialog_responses; + std::list> file_responses; } +#ifdef __WXMSW__ +// A GUI-subsystem application's entry point on Windows is WinMain, which wx +// provides; --cli mode is not available there yet wxIMPLEMENT_APP(AegisubApp); +#else +wxIMPLEMENT_APP_NO_MAIN(AegisubApp); +#endif static const char *LastStartupState = nullptr; @@ -125,36 +148,9 @@ wxDEFINE_EVENT(EVT_CALL_THUNK, ValueEvent); /// Message displayed when an exception has occurred. static wxString exception_message = "Oops, Aegisub has crashed!\n\nAn attempt has been made to save a copy of your file to:\n\n%s\n\nAegisub will now close."; -/// @brief Gets called when application starts. -/// @return bool -bool AegisubApp::OnInit() { - // App name (yeah, this is a little weird to get rid of an odd warning) -#if defined(__WXMSW__) || defined(__WXMAC__) - SetAppName("Aegisub"); -#else - SetAppName("aegisub"); -#endif - - // The logger isn't created on demand on background threads, so force it to - // be created now - (void)wxLog::GetActiveTarget(); - - agi::util::InitLocale(); - - agi::dispatch::Init([](agi::dispatch::Thunk f) { - auto evt = new ValueEvent(EVT_CALL_THUNK, -1, std::move(f)); - wxTheApp->QueueEvent(evt); - }); - - wxTheApp->Bind(EVT_CALL_THUNK, [this](ValueEvent& evt) { - try { - evt.Get()(); - } - catch (...) { - OnExceptionInMainLoop(); - } - }); - +/// @brief Non-GUI-specific initialization, shared between the GUI startup +/// path and CLI mode. @a app is null when running without a wxApp. +bool AegisubInitialize(AegisubApp *app, std::function showError) { config::path = new agi::Path; agi::log::log = new agi::log::LogSink; @@ -181,16 +177,20 @@ bool AegisubApp::OnInit() { #endif crash_writer::Initialize(config::path->Decode("?user")); - StartupLog("Create log writer"); - auto path_log = config::path->Decode("?user/log/"); - agi::fs::CreateDirectory(path_log); - agi::log::log->Subscribe(std::make_unique(path_log)); - CleanCache(path_log, "*.json", 10, 100); + if (config::hasGui) { + StartupLog("Create log writer"); + auto path_log = config::path->Decode("?user/log/"); + agi::fs::CreateDirectory(path_log); + agi::log::log->Subscribe(std::make_unique(path_log)); + CleanCache(path_log, "*.json", 10, 100); + } StartupLog("Load user configuration"); try { if (!config::opt) - config::opt = new agi::Options(config::path->Decode("?user/config.json"), GET_DEFAULT_CONFIG(default_config)); + // CLI mode reads the user's config but never writes it back + config::opt = new agi::Options(config::path->Decode("?user/config.json"), GET_DEFAULT_CONFIG(default_config), + config::hasGui ? agi::Options::NONE : agi::Options::FLUSH_SKIP); } catch (agi::Exception& e) { LOG_E("config/init") << "Caught exception: " << e.GetMessage(); } @@ -199,7 +199,7 @@ bool AegisubApp::OnInit() { config::opt->ConfigUser(); } catch (agi::Exception const& err) { - wxMessageBox(fmt_tl("Configuration file is invalid. Error reported:\n%s", err.GetMessage()), _("Error")); + showError(from_wx(fmt_tl("Configuration file is invalid. Error reported:\n%s", err.GetMessage())), from_wx(_("Error"))); } #ifdef _WIN32 @@ -221,7 +221,7 @@ bool AegisubApp::OnInit() { hotkey::init(); StartupLog("Load MRU"); - config::mru = new agi::MRUManager(config::path->Decode("?user/mru.json"), GET_DEFAULT_CONFIG(default_mru), config::opt); + config::mru = new agi::MRUManager(config::hasGui ? config::path->Decode("?user/mru.json") : "", GET_DEFAULT_CONFIG(default_mru), config::opt); agi::util::SetThreadName("AegiMain"); @@ -247,13 +247,14 @@ bool AegisubApp::OnInit() { StartupLog("Initialize final locale"); - // Set locale - auto lang = OPT_GET("App/Language")->GetString(); - if (lang.empty() || (lang != "en_US" && !locale.HasLanguage(lang))) { - lang = locale.PickLanguage(); - OPT_SET("App/Language")->SetString(lang); + if (app) { + auto lang = OPT_GET("App/Language")->GetString(); + if (lang.empty() || (lang != "en_US" && !app->locale.HasLanguage(lang))) { + lang = app->locale.PickLanguage(); + OPT_SET("App/Language")->SetString(lang); + } + app->locale.Init(lang); } - locale.Init(lang); #ifdef __APPLE__ // When run from an app bundle, LC_CTYPE defaults to "C", which breaks on @@ -265,21 +266,316 @@ bool AegisubApp::OnInit() { exception_message = _("Oops, Aegisub has crashed!\n\nAn attempt has been made to save a copy of your file to:\n\n%s\n\nAegisub will now close."); - agi::xdp_utils::Initialize(); + // The portal is only used by GUI dialogs, and xdp_portal_new() + // aborts the process if there's no D-Bus session to connect to + if (config::hasGui) + agi::xdp_utils::Initialize(); // Load plugins Automation4::ScriptFactory::Register(std::make_unique()); libass::CacheFonts(); // Load Automation scripts - StartupLog("Load global Automation scripts"); - config::global_scripts = new Automation4::AutoloadScriptManager(OPT_GET("Path/Automation/Autoload")->GetString()); + if (config::loadGlobalAutomation) { + StartupLog("Load global Automation scripts"); + config::global_scripts = new Automation4::AutoloadScriptManager(OPT_GET("Path/Automation/Autoload")->GetString()); + + // Load export filters + StartupLog("Register export filters"); + AssExportFilterChain::Register(std::make_unique()); + AssExportFilterChain::Register(std::make_unique()); + } + } + catch (agi::Exception const& e) { + showError(e.GetMessage(), from_wx(_("Fatal error while initializing"))); + return false; + } + catch (std::exception const& e) { + showError(e.what(), from_wx(_("Fatal error while initializing"))); + return false; + } +#ifndef _DEBUG + catch (...) { + showError(from_wx(_("Unhandled exception")), from_wx(_("Fatal error while initializing"))); + return false; + } +#endif + return true; +} + +#ifndef __WXMSW__ +std::unique_ptr find_script(const std::string& file) +{ + auto absolute = agi::fs::path(file); + auto relative = agi::fs::path(std::filesystem::current_path()) / file; + + agi::fs::path script; - // Load export filters - StartupLog("Register export filters"); - AssExportFilterChain::Register(std::make_unique()); - AssExportFilterChain::Register(std::make_unique()); + if (agi::fs::FileExists(absolute)) { + script = absolute; + } else if (agi::fs::FileExists(relative)) { + script = relative; + } else { + auto autodirs = OPT_GET("Path/Automation/Autoload")->GetString(); + + for (auto tok : agi::Split(autodirs, '|')) { + auto dirname = config::path->Decode(std::string(tok)); + if (!agi::fs::DirectoryExists(dirname)) continue; + + auto scriptname = dirname / file; + if (agi::fs::FileExists(scriptname)) { + script = scriptname; + } + } + } + + if (script.empty()) { + throw agi::InvalidInputException("Could not find script file: " + file); + } + + return Automation4::ScriptFactory::CreateFromFile(script, true, false); +} + +/// @brief Gets called when application starts. +/// @return int +int main(int argc, char *argv[]) { + wxDISABLE_DEBUG_SUPPORT(); + + CLI::App app("Aegisub - Advanced Subtitle Editor"); + + bool cli = false; + std::string in_file, out_file, macro; + std::string video, timecodes, keyframes; + std::string selected_range; + int active_index = -1; + std::vector automation_scripts, dialog_json, file_names; + + // The positionals can also be passed as named options + auto in_file_opt = app.add_option("in-file,--in-file", in_file, "input file"); + auto out_file_opt = app.add_option("out-file,--out-file", out_file, "output file"); + auto macro_opt = app.add_option("macro,--macro", macro, "macro to run"); + + app.add_flag("--cli", cli, "run in CLI mode, without a GUI window. Enables the other options"); + app.add_option("--video", video, "video to load"); + app.add_option("--timecodes", timecodes, "timecodes to load"); + app.add_option("--keyframes", keyframes, "keyframes to load"); + // allow_extra_args(false) makes the repeatable options consume exactly + // one value per occurrence rather than greedily eating the positionals + app.add_option("--automation", automation_scripts, "an automation script to run")->allow_extra_args(false); + app.add_option("--active-line", active_index, "the active line")->capture_default_str(); + app.add_option("--selected-lines", selected_range, "the selected lines"); + app.add_option("--dialog", dialog_json, "response to a dialog, in JSON")->allow_extra_args(false); + app.add_option("--file", file_names, "filename to supply to an open/save call")->allow_extra_args(false); + + // Extra arguments are collected rather than rejected so that GUI + // launches keep working with toolkit arguments (wx parses the original + // argv itself); CLI mode rejects them below + app.allow_extras(); + + try { + app.parse(argc, argv); + } + catch (CLI::Success const& e) { + // --help and friends print and exit successfully + return app.exit(e); + } + catch (CLI::ParseError const& e) { + app.exit(e); // prints the error and a --help hint to stderr + return 1; + } + + config::hasGui = !cli; + + if (cli) { + auto extras = app.remaining(); + if (!extras.empty()) { + std::cerr << "Error: unrecognised option '" << extras.front() << "'" << std::endl; + return 1; + } + + if (!in_file_opt->count() || !out_file_opt->count() || !macro_opt->count()) { + std::cerr << "Too few arguments." << std::endl; + std::cerr << app.help() << std::endl; + return 1; + } + } + agi::util::InitLocale(); + + if (cli) { + config::hasInitializedWx = false; + config::loadGlobalAutomation = false; + + // Scripts run on a worker thread while this thread pumps main-thread + // thunks, mirroring the GUI threading model (see cli::RunWithMainLoop) + cli::InitDispatch(); + + if (!AegisubInitialize(nullptr, [&](std::string msg, std::string title) { std::cerr << title << ": " << msg << "\n"; })) { + return -1; + } + + try { + agi::Context context; + + LOG_D("main") << "Loading subtitles..."; + if (!context.project->LoadSubtitles(agi::fs::path(std::filesystem::absolute(in_file)), "", false)) { + std::cerr << "Failed to load " << in_file << std::endl; + return 1; + } + + if (!video.empty()) { + LOG_D("main") << "Loading video..."; + // Dummy video "filenames" aren't paths, so leave them alone + context.project->LoadVideo(video.starts_with("?dummy") ? agi::fs::path(video) : agi::fs::path(std::filesystem::absolute(video))); + } + + if (!timecodes.empty()) { + LOG_D("main") << "Loading timecodes..."; + context.project->LoadTimecodes(agi::fs::path(std::filesystem::absolute(timecodes))); + } + + if (!keyframes.empty()) { + LOG_D("main") << "Loading keyframes..."; + context.project->LoadKeyframes(agi::fs::path(std::filesystem::absolute(keyframes))); + } + + AssDialogue* active_line = nullptr; + + auto selected_indices = parse_range(selected_range); + Selection selected_lines; + + int i = 0; + for (auto& line : context.ass->Events) { + if (i == active_index) { + active_line = &line; + } + + if (selected_indices.empty() || selected_indices.count(i)) { + selected_lines.insert(&line); + if (active_line == nullptr) { + // assign first line in selection as a fallback + active_line = &line; + } + } + i++; + } + + if (active_line == nullptr) { + // selection was empty + active_line = &context.ass->Events.front(); + selected_lines.insert(active_line); + } + + context.selectionController->SetSelectionAndActive( + std::move(selected_lines), active_line); + + if (!dialog_json.empty()) + config::dialog_responses = parse_dialog_responses(dialog_json); + + if (!file_names.empty()) + config::file_responses = parse_file_responses(file_names); + + // cache cwd in case automation changes it + auto cwd = std::filesystem::current_path(); + + std::vector> scripts; + for (auto& s : automation_scripts) { + LOG_D("main") << "Loading " << s; + auto script = find_script(s); + if (!script) { + return 1; + } + scripts.emplace_back(std::move(script)); + } + + cmd::Command *cmd = nullptr; + + // Allow calling automation scripts by their display name + for (auto const& script : scripts) { + for (auto const& c: script->GetMacros()) { + if (c->StrMenu(&context) == to_wx(macro)) { + cmd = c; + } + } + } + + // If we don't find one, try the command name instead + if (!cmd) { + try { + cmd = cmd::get(macro); + } catch (cmd::CommandNotFound const&) { + std::cerr << "Command not found: " << macro << std::endl; + LOG_E("main") << "Command not found: " << macro; + return 1; + } + } + + if (!cmd->Validate(&context)) { + LOG_E("main") << "Skipping automation because validation function returned false"; + return 1; + } + + LOG_D("main") << "Calling " << cmd->name(); + (*cmd)(&context); + + // restore cwd for saving + std::filesystem::current_path(cwd); + context.subsController->Save(agi::fs::path(std::filesystem::absolute(out_file))); + + if (config::hasInitializedWx) { + wxUninitialize(); + } + return 0; + } + catch (agi::Exception const& e) { + std::cerr << "Error: " << e.GetMessage() << std::endl; + return 1; + } + catch (std::exception const& e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + } else { + return wxEntry(argc, argv); + } +} +#endif // !__WXMSW__ + + +/// @brief wx's initialization function. Called from main() to initialize the GUI-specific stuff and then call Initialize() +/// @return bool +bool AegisubApp::OnInit() { + // App name (yeah, this is a little weird to get rid of an odd warning) +#if defined(__WXMSW__) || defined(__WXMAC__) + SetAppName("Aegisub"); +#else + SetAppName("aegisub"); +#endif + + // The logger isn't created on demand on background threads, so force it to + // be created now + (void)wxLog::GetActiveTarget(); + + // Pointless `this` capture required due to http://gcc.gnu.org/bugzilla/show_bug.cgi?id=51494 + agi::dispatch::Init([this](agi::dispatch::Thunk f) { + auto evt = new ValueEvent(EVT_CALL_THUNK, -1, std::move(f)); + wxTheApp->QueueEvent(evt); + }); + + wxTheApp->Bind(EVT_CALL_THUNK, [this](ValueEvent& evt) { + try { + evt.Get()(); + } + catch (...) { + OnExceptionInMainLoop(); + } + }); + + if (!::AegisubInitialize(this, [](std::string msg, std::string title) { wxMessageBox(to_wx(msg), to_wx(title)); })) { + return false; + } + + try { StartupLog("Install PNG handler"); wxImage::AddHandler(new wxPNGHandler); diff --git a/src/meson.build b/src/meson.build index 876d551ffb..b8808d922a 100644 --- a/src/meson.build +++ b/src/meson.build @@ -35,6 +35,7 @@ aegisub_src = files( 'auto4_lua_progresssink.cpp', 'base_grid.cpp', 'charset_detect.cpp', + 'cli.cpp', 'colorspace.cpp', 'colour_button.cpp', 'command/app.cpp', @@ -93,6 +94,7 @@ aegisub_src = files( 'gl_text.cpp', 'gl_wrap.cpp', 'grid_column.cpp', + 'gui_wrap.cpp', 'help_button.cpp', 'hotkey.cpp', 'hotkey_data_view_model.cpp', diff --git a/src/mkv_wrap.cpp b/src/mkv_wrap.cpp index ba56834951..2f2c61dcce 100644 --- a/src/mkv_wrap.cpp +++ b/src/mkv_wrap.cpp @@ -38,6 +38,7 @@ #include "ass_parser.h" #include "compat.h" #include "dialog_progress.h" +#include "gui_wrap.h" #include "MatroskaParser.h" #include "options.h" #include "subtitle_format_srt.h" @@ -54,8 +55,6 @@ #include #include -#include // Keep this last so wxUSE_CHOICEDLG is set. - struct MkvStdIO final : InputStream { agi::read_file_mapping file; std::string error; @@ -259,7 +258,7 @@ void MatroskaWrapper::GetSubtitles(agi::fs::path const& filename, AssFile *targe trackToRead = tracksFound[0]; // Pick a track else { - int choice = wxGetSingleChoiceIndex(_("Choose which track to read:"), _("Multiple subtitle tracks found"), to_wx(tracksNames)); + int choice = wrapChoiceDialog("subtitle-track", _("Choose which track to read:"), _("Multiple subtitle tracks found"), to_wx(tracksNames)); if (choice == -1) throw agi::UserCancelException("canceled"); @@ -304,7 +303,7 @@ void MatroskaWrapper::GetSubtitles(agi::fs::path const& filename, AssFile *targe // Progress bar auto totalTime = double(segInfo->Duration) / timecodeScale; - DialogProgress progress(nullptr, _("Parsing Matroska"), _("Reading subtitles from Matroska file.")); + OptDialogProgress progress(nullptr, _("Parsing Matroska"), _("Reading subtitles from Matroska file.")); bool result; progress.Run([&](agi::ProgressSink *ps) { result = read_subtitles(ps, file, &input, srt, totalTime, &parser, cs); }); diff --git a/src/options.h b/src/options.h index 77e6676a2c..b1d308a181 100644 --- a/src/options.h +++ b/src/options.h @@ -16,10 +16,17 @@ #pragma once +#include #include #include #include +#include +#include +#include +#include +#include + namespace agi { class Path; } namespace Automation4 { class AutoloadScriptManager; } @@ -29,6 +36,12 @@ namespace config { extern agi::MRUManager *mru; ///< Most Recently Used extern agi::Path *path; extern Automation4::AutoloadScriptManager *global_scripts; + + extern bool hasGui; + extern bool hasInitializedWx; + extern std::map> choice_indices; + extern std::list> dialog_responses; + extern std::list> file_responses; } /// Macro to get OptionValue object diff --git a/src/project.cpp b/src/project.cpp index e1ef6170ec..14a5350ba4 100644 --- a/src/project.cpp +++ b/src/project.cpp @@ -28,6 +28,7 @@ #include "dialog_progress.h" #include "dialogs.h" #include "format.h" +#include "gui_wrap.h" #include "include/aegisub/context.h" #include "include/aegisub/video_provider.h" #include "mkv_wrap.h" @@ -86,7 +87,7 @@ void Project::ReloadVideo() { } void Project::ShowError(wxString const& message) { - wxMessageBox(message, _("Error loading file"), wxOK | wxICON_ERROR | wxCENTER, context->parent); + wrapMessageBox(message, _("Error loading file"), wxOK | wxICON_ERROR | wxCENTER, context->parent); } void Project::ShowError(std::string const& message) { @@ -155,20 +156,22 @@ bool Project::DoLoadSubtitles(agi::fs::path const& path, std::string encoding, P sel.insert(active_line); } context->selectionController->SetSelectionAndActive(std::move(sel), active_line); - context->subsGrid->ScrollTo(properties.scroll_position); + if (config::hasGui) + context->subsGrid->ScrollTo(properties.scroll_position); return true; } -void Project::LoadSubtitles(agi::fs::path path, std::string encoding, bool load_linked) { +bool Project::LoadSubtitles(agi::fs::path path, std::string encoding, bool load_linked) { ProjectProperties properties; if (!DoLoadSubtitles(path, encoding, properties)) - return; + return false; if (load_linked) LoadUnloadFiles(properties); else UpdateRelativePaths(); + return true; } void Project::SetSubtitlesFilename(agi::fs::path path) { @@ -318,7 +321,7 @@ void Project::LoadUnloadFiles(ProjectProperties properties) { void Project::DoLoadAudio(agi::fs::path const& path, bool quiet) { if (!progress) - progress = new DialogProgress(context->parent); + progress = new OptDialogProgress(context->parent); try { try { @@ -364,7 +367,7 @@ void Project::CloseAudio() { bool Project::DoLoadVideo(agi::fs::path const& path) { if (!progress) - progress = new DialogProgress(context->parent); + progress = new OptDialogProgress(context->parent); try { video_provider = std::make_unique(path, context->ass->GetYCbCrMatrix(), context->videoController.get(), progress); @@ -394,7 +397,7 @@ bool Project::DoLoadVideo(agi::fs::path const& path) { std::string warning = video_provider->GetWarning(); if (!warning.empty()) - wxMessageBox(to_wx(warning), _("Warning"), wxICON_WARNING | wxOK); + wrapMessageBox(to_wx(warning), _("Warning"), wxICON_WARNING | wxOK); video_has_subtitles = false; if (agi::fs::HasExtension(path, "mkv")) diff --git a/src/project.h b/src/project.h index 876ddd9613..1b826c76f5 100644 --- a/src/project.h +++ b/src/project.h @@ -24,7 +24,7 @@ #include class AsyncVideoProvider; -class DialogProgress; +class OptDialogProgress; class wxString; namespace agi { class AudioProvider; } namespace agi { struct Context; } @@ -47,7 +47,7 @@ class Project : private agi::signal::ConnectionScope { agi::signal::Signal const&> AnnounceKeyframesModified; bool video_has_subtitles = false; - DialogProgress *progress = nullptr; + OptDialogProgress *progress = nullptr; agi::Context *context = nullptr; void ShowError(wxString const& message); @@ -70,7 +70,8 @@ class Project : private agi::signal::ConnectionScope { Project(agi::Context *context); ~Project(); - void LoadSubtitles(agi::fs::path path, std::string encoding="", bool load_linked=true); + /// @return Whether the file was loaded as subtitles + bool LoadSubtitles(agi::fs::path path, std::string encoding="", bool load_linked=true); void SetSubtitlesFilename(agi::fs::path path); void CloseSubtitles(); bool CanLoadSubtitlesFromVideo() const { return video_has_subtitles; } diff --git a/src/subs_controller.cpp b/src/subs_controller.cpp index d747c4868d..edc507f120 100644 --- a/src/subs_controller.cpp +++ b/src/subs_controller.cpp @@ -149,10 +149,13 @@ SubsController::SubsController(agi::Context *context) , text_selection_connection(context->textSelectionController->AddSelectionListener(&SubsController::OnTextSelectionChanged, this)) , autosave_queue(agi::dispatch::Create()) { - autosave_timer_changed(&autosave_timer); - BindConnection(OPT_SUB("App/Auto/Save", [this] { autosave_timer_changed(&autosave_timer); })); - BindConnection(OPT_SUB("App/Auto/Save Every Seconds", [this] { autosave_timer_changed(&autosave_timer); })); - autosave_timer.Bind(wxEVT_TIMER, [this](wxTimerEvent&) { AutoSave(); }); + if (config::hasGui) { + autosave_timer = std::make_unique(); + autosave_timer_changed(autosave_timer.get()); + BindConnection(OPT_SUB("App/Auto/Save", [this] { autosave_timer_changed(autosave_timer.get()); })); + BindConnection(OPT_SUB("App/Auto/Save Every Seconds", [this] { autosave_timer_changed(autosave_timer.get()); })); + autosave_timer->Bind(wxEVT_TIMER, [this](wxTimerEvent&) { AutoSave(); }); + } } SubsController::~SubsController() { @@ -182,7 +185,7 @@ ProjectProperties SubsController::Load(agi::fs::path const& filename, const char context->ass->Commit("", AssFile::COMMIT_NEW); // Save backup of file - if (CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) { + if (config::hasGui && CanSave() && OPT_GET("App/Auto/Backup")->GetBool()) { auto path_str = OPT_GET("Path/Auto/Backup")->GetString(); agi::fs::path path; if (path_str.empty()) diff --git a/src/subs_controller.h b/src/subs_controller.h index c8cf9b9ae5..2a120ed619 100644 --- a/src/subs_controller.h +++ b/src/subs_controller.h @@ -55,7 +55,7 @@ class SubsController : private agi::signal::ConnectionScope { int next_commit_id = 1; /// Timer for triggering autosaves - wxTimer autosave_timer; + std::unique_ptr autosave_timer; /// Queue which autosaves are performed on std::unique_ptr autosave_queue; diff --git a/src/subtitle_format.cpp b/src/subtitle_format.cpp index 387059ce54..82c3ebb63c 100644 --- a/src/subtitle_format.cpp +++ b/src/subtitle_format.cpp @@ -37,6 +37,7 @@ #include "ass_dialogue.h" #include "ass_file.h" #include "compat.h" +#include "gui_wrap.h" #include "format.h" #include "subtitle_format_ass.h" #include "subtitle_format_ebu3264.h" @@ -123,7 +124,7 @@ agi::vfr::Framerate SubtitleFormat::AskForFPS(bool allow_vfr, bool show_smpte, a bool was_busy = wxIsBusy(); if (was_busy) wxEndBusyCursor(); - int choice = wxGetSingleChoiceIndex(_("Please choose the appropriate FPS for the subtitles:"), _("FPS"), choices); + int choice = wrapChoiceDialog("subtitle-fps", _("Please choose the appropriate FPS for the subtitles:"), _("FPS"), choices); if (was_busy) wxBeginBusyCursor(); using agi::vfr::Framerate; diff --git a/src/video_controller.cpp b/src/video_controller.cpp index eb6fb9d574..25bf734c6a 100644 --- a/src/video_controller.cpp +++ b/src/video_controller.cpp @@ -57,11 +57,16 @@ VideoController::VideoController(agi::Context *c) { Bind(EVT_VIDEO_ERROR, &VideoController::OnVideoError, this); Bind(EVT_SUBTITLES_ERROR, &VideoController::OnSubtitlesError, this); - playback.Bind(wxEVT_TIMER, &VideoController::OnPlayTimer, this); + + if (config::hasGui) { + playback = std::make_unique(); + playback->Bind(wxEVT_TIMER, &VideoController::OnPlayTimer, this); + } } void VideoController::OnNewVideoProvider(AsyncVideoProvider *new_provider) { - Stop(); + if (config::hasGui) + Stop(); provider = new_provider; color_matrix = std::nullopt; } @@ -84,7 +89,7 @@ void VideoController::OnSubtitlesCommit(int type, const AssDialogue *changed) { } void VideoController::OnActiveLineChanged(AssDialogue *line) { - if (line && provider && OPT_GET("Video/Subtitle Sync")->GetBool()) { + if (config::hasGui && line && provider && OPT_GET("Video/Subtitle Sync")->GetBool()) { Stop(); JumpToTime(line->Start); } @@ -133,6 +138,8 @@ void VideoController::PrevFrame() { } void VideoController::Play() { + // There's no playback in CLI mode + if (!config::hasGui) return; if (IsPlaying()) { Stop(); return; @@ -146,10 +153,11 @@ void VideoController::Play() { context->audioController->PlayToEnd(start_ms); playback_start_time = std::chrono::steady_clock::now(); - playback.Start(10); + playback->Start(10); } void VideoController::PlayLine() { + if (!config::hasGui) return; Stop(); AssDialogue *curline = context->selectionController->GetActiveLine(); @@ -165,12 +173,12 @@ void VideoController::PlayLine() { JumpToFrame(startFrame); playback_start_time = std::chrono::steady_clock::now(); - playback.Start(10); + playback->Start(10); } void VideoController::Stop() { if (IsPlaying()) { - playback.Stop(); + playback->Stop(); context->audioController->Stop(); } } @@ -189,6 +197,10 @@ void VideoController::OnPlayTimer(wxTimerEvent &) { } } +bool VideoController::IsPlaying() const { + return config::hasGui && playback->IsRunning(); +} + double VideoController::GetARFromType(AspectRatio type) const { switch (type) { case AspectRatio::Default: return (double)provider->GetWidth()/provider->GetHeight(); diff --git a/src/video_controller.h b/src/video_controller.h index 70fd9d3cbd..9e70f6eca6 100644 --- a/src/video_controller.h +++ b/src/video_controller.h @@ -74,7 +74,7 @@ class VideoController final : public wxEvtHandler { /// Playback timer used to periodically check if we should go to the next /// frame while playing video - wxTimer playback; + std::unique_ptr playback; /// Time when playback was last started std::chrono::steady_clock::time_point playback_start_time; @@ -117,7 +117,7 @@ class VideoController final : public wxEvtHandler { VideoController(agi::Context *context); /// Is the video currently playing? - bool IsPlaying() const { return playback.IsRunning(); } + bool IsPlaying() const; /// Get the current frame number int GetFrameN() const { return frame_n; } diff --git a/subprojects/cli11.wrap b/subprojects/cli11.wrap new file mode 100644 index 0000000000..3fc918d63a --- /dev/null +++ b/subprojects/cli11.wrap @@ -0,0 +1,10 @@ +[wrap-file] +directory = CLI11-2.7.2 +source_url = https://github.com/CLIUtils/CLI11/archive/refs/tags/v2.7.2.tar.gz +source_filename = CLI11-2.7.2.tar.gz +source_hash = 46eef3101da70852ec7af026e09d485ccee81813331c8c6052d39344443b83da +source_fallback_url = https://wrapdb.mesonbuild.com/v2/cli11_2.7.2-1/get_source/CLI11-2.7.2.tar.gz +wrapdb_version = 2.7.2-1 + +[provide] +dependency_names = CLI11