From 88eb3ced5b6170c9efe9310af243291d70ca8e4f Mon Sep 17 00:00:00 2001 From: GigabiteStudios Date: Thu, 18 Jun 2026 00:49:32 -0500 Subject: [PATCH] feat(dialogs): add cross-platform filesystem dialogs --- .gitignore | 5 + CMakeLists.txt | 70 ++++++++++++ README.md | 48 +++++++- cmake/iZoConfig.cmake.in | 3 + examples/dialogs.cpp | 23 ++++ include/izo/dialogs.hpp | 50 +++++++++ src/dialogs.cpp | 5 + src/dialogs_linux.cpp | 204 ++++++++++++++++++++++++++++++++++ src/dialogs_windows.cpp | 231 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 CMakeLists.txt create mode 100644 cmake/iZoConfig.cmake.in create mode 100644 examples/dialogs.cpp create mode 100644 include/izo/dialogs.hpp create mode 100644 src/dialogs.cpp create mode 100644 src/dialogs_linux.cpp create mode 100644 src/dialogs_windows.cpp diff --git a/.gitignore b/.gitignore index e257658..4890174 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ # ---> C++ +# CMake build trees +build/ +cmake-build-*/ +CMakeUserPresets.json + # Prerequisites *.d diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b3b5d68 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,70 @@ +cmake_minimum_required(VERSION 3.16) + +project(iZo VERSION 0.1.0 LANGUAGES CXX) + +add_library(izo STATIC) +add_library(iZo::izo ALIAS izo) + +target_sources(izo + PRIVATE + src/dialogs.cpp + $<$:src/dialogs_windows.cpp> + $<$:src/dialogs_linux.cpp> +) + +target_include_directories(izo + PUBLIC + $ + $ +) +target_compile_features(izo PUBLIC cxx_std_17) +set_target_properties(izo PROPERTIES + CXX_EXTENSIONS OFF + OUTPUT_NAME izo +) + +if(WIN32) + target_compile_definitions(izo PRIVATE UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) + target_link_libraries(izo PRIVATE ole32 shell32 uuid) +elseif(UNIX AND NOT APPLE) + # No link-time desktop dependency. The Linux backend discovers zenity or + # kdialog at runtime so applications do not inherit GTK or Qt dependencies. +else() + message(FATAL_ERROR "iZo currently supports Windows and Linux") +endif() + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +install(TARGETS izo EXPORT iZoTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR} +) +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) +install(EXPORT iZoTargets + FILE iZoTargets.cmake + NAMESPACE iZo:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/iZo +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/iZoConfigVersion.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) +configure_package_config_file( + cmake/iZoConfig.cmake.in + "${CMAKE_CURRENT_BINARY_DIR}/iZoConfig.cmake" + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/iZo +) +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/iZoConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/iZoConfigVersion.cmake" + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/iZo +) + +option(IZO_BUILD_EXAMPLE "Build the iZo example program" OFF) +if(IZO_BUILD_EXAMPLE) + add_executable(izo_example examples/dialogs.cpp) + target_link_libraries(izo_example PRIVATE iZo::izo) +endif() diff --git a/README.md b/README.md index 607b7e5..2d08d68 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ # iZo -OS IO Interface. \ No newline at end of file +iZo is a small C++17 static library for native file-system dialogs on Windows +and Linux. + +## Features + +- Open file, including multi-select and file filters +- Save file +- Pick folder +- Open a path with its default application +- Reveal a path in Explorer or the desktop file manager +- Explicit selected, cancelled, and error result states + +Windows uses the native Explorer `IFileDialog` API. Linux discovers `zenity` or +`kdialog` at runtime; at least one must be installed for dialogs. Path opening +uses `xdg-open`. Revealing uses the FreeDesktop file-manager D-Bus interface +when available and otherwise opens the parent folder. + +## Build + +```sh +cmake -S . -B build -DIZO_BUILD_EXAMPLE=ON +cmake --build build +``` + +To consume an installed copy: + +```cmake +find_package(iZo CONFIG REQUIRED) +target_link_libraries(your_target PRIVATE iZo::izo) +``` + +## Usage + +```cpp +#include + +izo::dialog_options options; +options.title = "Choose an image"; +options.filters = {{"Images", {"*.png", "*.jpg"}}}; + +auto result = izo::open_file(options); +if (result) { + auto selected_path = result.paths.front(); +} else if (result.status == izo::dialog_status::error) { + // result.error_message contains the platform failure. +} +``` diff --git a/cmake/iZoConfig.cmake.in b/cmake/iZoConfig.cmake.in new file mode 100644 index 0000000..7552f67 --- /dev/null +++ b/cmake/iZoConfig.cmake.in @@ -0,0 +1,3 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/iZoTargets.cmake") diff --git a/examples/dialogs.cpp b/examples/dialogs.cpp new file mode 100644 index 0000000..ccc9d73 --- /dev/null +++ b/examples/dialogs.cpp @@ -0,0 +1,23 @@ +#include + +#include + +int main() { + izo::dialog_options options; + options.title = "Choose an image"; + options.filters = { + {"Images", {"*.png", "*.jpg", "*.jpeg"}}, + {"All files", {"*"}}, + }; + + const auto result = izo::open_file(options); + if (result) { + std::cout << result.paths.front().string() << '\n'; + return 0; + } + if (result.status == izo::dialog_status::error) { + std::cerr << result.error_message << '\n'; + return 1; + } + return 0; +} diff --git a/include/izo/dialogs.hpp b/include/izo/dialogs.hpp new file mode 100644 index 0000000..daf2c34 --- /dev/null +++ b/include/izo/dialogs.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include + +namespace izo { + +enum class dialog_status { + selected, + cancelled, + error, +}; + +struct file_filter { + std::string name; + // Glob patterns, for example {"*.png", "*.jpg"}. + std::vector patterns; +}; + +struct dialog_options { + std::string title; + std::filesystem::path initial_path; + std::vector filters; + bool allow_multiple = false; + bool show_hidden = false; +}; + +struct dialog_result { + dialog_status status = dialog_status::cancelled; + std::vector paths; + std::string error_message; + + explicit operator bool() const noexcept { return status == dialog_status::selected; } +}; + +// These calls block until the user closes the native desktop dialog. +dialog_result open_file(const dialog_options& options = {}); +dialog_result save_file(const dialog_options& options = {}); +dialog_result pick_folder(const dialog_options& options = {}); + +// Opens a file/folder using the operating system's registered default handler. +// Returns false and optionally fills error_message if launching it failed. +bool open_path(const std::filesystem::path& path, std::string* error_message = nullptr); + +// Opens the containing file manager and selects path when the platform supports it. +bool reveal_in_file_manager(const std::filesystem::path& path, + std::string* error_message = nullptr); + +} // namespace izo diff --git a/src/dialogs.cpp b/src/dialogs.cpp new file mode 100644 index 0000000..491c62f --- /dev/null +++ b/src/dialogs.cpp @@ -0,0 +1,5 @@ +#include + +// Platform implementations live in dialogs_windows.cpp and dialogs_linux.cpp. +// This translation unit ensures the archive always has a platform-independent +// object and is a natural home for future shared helpers. diff --git a/src/dialogs_linux.cpp b/src/dialogs_linux.cpp new file mode 100644 index 0000000..650b508 --- /dev/null +++ b/src/dialogs_linux.cpp @@ -0,0 +1,204 @@ +#include + +#include +#include +#include + +#include +#include +#include +#include + +namespace izo { +namespace { + +struct process_result { + int exit_code = -1; + std::string output; + std::string error; +}; + +bool executable_on_path(const char* name) { + const char* path = std::getenv("PATH"); + if (!path) return false; + std::stringstream stream(path); + std::string directory; + while (std::getline(stream, directory, ':')) { + if (directory.empty()) directory = "."; + if (access((directory + '/' + name).c_str(), X_OK) == 0) return true; + } + return false; +} + +process_result run(const std::vector& arguments) { + int output_pipe[2]; + if (pipe(output_pipe) != 0) return {-1, {}, std::strerror(errno)}; + + const pid_t child = fork(); + if (child < 0) { + close(output_pipe[0]); close(output_pipe[1]); + return {-1, {}, std::strerror(errno)}; + } + if (child == 0) { + close(output_pipe[0]); + dup2(output_pipe[1], STDOUT_FILENO); + dup2(output_pipe[1], STDERR_FILENO); + close(output_pipe[1]); + std::vector argv; + argv.reserve(arguments.size() + 1); + for (const auto& argument : arguments) argv.push_back(const_cast(argument.c_str())); + argv.push_back(nullptr); + execvp(argv[0], argv.data()); + _exit(127); + } + + close(output_pipe[1]); + std::string output; + char buffer[4096]; + ssize_t count; + while ((count = read(output_pipe[0], buffer, sizeof(buffer))) > 0) { + output.append(buffer, static_cast(count)); + } + close(output_pipe[0]); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} + const int exit_code = WIFEXITED(status) ? WEXITSTATUS(status) : -1; + return {exit_code, std::move(output), {}}; +} + +std::string initial_value(const dialog_options& options, bool save) { + if (options.initial_path.empty()) return {}; + auto value = options.initial_path.string(); + std::error_code ec; + if (!save && std::filesystem::is_directory(options.initial_path, ec) && + !value.empty() && value.back() != '/') value.push_back('/'); + return value; +} + +dialog_result parse_dialog_output(process_result process, char separator) { + if (process.exit_code == 1) return {dialog_status::cancelled, {}, {}}; + if (process.exit_code != 0) { + std::string message = process.error.empty() ? process.output : process.error; + while (!message.empty() && (message.back() == '\n' || message.back() == '\r')) message.pop_back(); + if (message.empty()) message = "Desktop dialog process failed with code " + + std::to_string(process.exit_code); + return {dialog_status::error, {}, std::move(message)}; + } + while (!process.output.empty() && + (process.output.back() == '\n' || process.output.back() == '\r')) process.output.pop_back(); + if (process.output.empty()) return {dialog_status::cancelled, {}, {}}; + dialog_result result{dialog_status::selected, {}, {}}; + std::stringstream stream(process.output); + std::string path; + while (std::getline(stream, path, separator)) { + if (!path.empty()) result.paths.emplace_back(std::move(path)); + } + return result; +} + +dialog_result zenity_dialog(const dialog_options& options, bool save, bool folder) { + constexpr char separator = '\x1f'; + std::vector args{"zenity", "--file-selection"}; + if (save) args.emplace_back("--save"); + if (folder) args.emplace_back("--directory"); + if (!save && options.allow_multiple) { + args.emplace_back("--multiple"); + args.emplace_back(std::string("--separator=") + separator); + } + if (options.show_hidden) args.emplace_back("--show-hidden"); + if (!options.title.empty()) args.emplace_back("--title=" + options.title); + const auto initial = initial_value(options, save); + if (!initial.empty()) args.emplace_back("--filename=" + initial); + for (const auto& filter : options.filters) { + std::string value = "--file-filter=" + filter.name + " |"; + for (const auto& pattern : filter.patterns) value += ' ' + pattern; + args.push_back(std::move(value)); + } + return parse_dialog_output(run(args), separator); +} + +dialog_result kdialog_dialog(const dialog_options& options, bool save, bool folder) { + std::vector args{"kdialog"}; + if (folder) args.emplace_back("--getexistingdirectory"); + else if (save) args.emplace_back("--getsavefilename"); + else args.emplace_back("--getopenfilename"); + const auto initial = initial_value(options, save); + args.push_back(initial.empty() ? std::string(".") : initial); + if (!folder && !options.filters.empty()) { + std::string filters; + for (const auto& filter : options.filters) { + if (!filters.empty()) filters += "\n"; + for (std::size_t i = 0; i < filter.patterns.size(); ++i) { + if (i) filters += ' '; + filters += filter.patterns[i]; + } + filters += '|' + filter.name; + } + args.push_back(std::move(filters)); + } + if (!options.title.empty()) { args.emplace_back("--title"); args.push_back(options.title); } + if (!save && options.allow_multiple) { + args.emplace_back("--multiple"); + args.emplace_back("--separate-output"); + } + return parse_dialog_output(run(args), '\n'); +} + +dialog_result show_dialog(const dialog_options& options, bool save, bool folder) { + if (executable_on_path("zenity")) return zenity_dialog(options, save, folder); + if (executable_on_path("kdialog")) return kdialog_dialog(options, save, folder); + return {dialog_status::error, {}, + "No supported desktop dialog provider found; install zenity or kdialog"}; +} + +bool launch_and_report(const std::vector& args, std::string* error_message) { + auto result = run(args); + if (result.exit_code == 0) return true; + if (error_message) { + *error_message = result.output.empty() + ? "Process failed with code " + std::to_string(result.exit_code) + : std::move(result.output); + } + return false; +} + +std::string file_uri(const std::filesystem::path& path) { + // D-Bus accepts file URIs. Percent-encode bytes outside the unreserved URI set. + const auto absolute = std::filesystem::absolute(path).string(); + std::ostringstream uri; + uri << "file://"; + constexpr char hex[] = "0123456789ABCDEF"; + for (const unsigned char c : absolute) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '_' || c == '.' || c == '~') { + uri << static_cast(c); + } else { + uri << '%' << hex[c >> 4] << hex[c & 15]; + } + } + return uri.str(); +} + +} // namespace + +dialog_result open_file(const dialog_options& options) { return show_dialog(options, false, false); } +dialog_result save_file(const dialog_options& options) { return show_dialog(options, true, false); } +dialog_result pick_folder(const dialog_options& options) { return show_dialog(options, false, true); } + +bool open_path(const std::filesystem::path& path, std::string* error_message) { + return launch_and_report({"xdg-open", path.string()}, error_message); +} + +bool reveal_in_file_manager(const std::filesystem::path& path, std::string* error_message) { + if (executable_on_path("dbus-send")) { + auto result = run({"dbus-send", "--session", "--dest=org.freedesktop.FileManager1", + "--type=method_call", "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + "array:string:" + file_uri(path), "string:"}); + if (result.exit_code == 0) return true; + } + const auto parent = std::filesystem::is_directory(path) ? path : path.parent_path(); + return open_path(parent.empty() ? std::filesystem::path(".") : parent, error_message); +} + +} // namespace izo diff --git a/src/dialogs_windows.cpp b/src/dialogs_windows.cpp new file mode 100644 index 0000000..e3f4340 --- /dev/null +++ b/src/dialogs_windows.cpp @@ -0,0 +1,231 @@ +#include + +#include +#include +#include + +#include +#include +#include + +namespace izo { +namespace { + +template +class com_ptr { +public: + ~com_ptr() { reset(); } + com_ptr() = default; + com_ptr(const com_ptr&) = delete; + com_ptr& operator=(const com_ptr&) = delete; + T** put() { reset(); return &value_; } + T* get() const { return value_; } + T* operator->() const { return value_; } + void reset() { if (value_) value_->Release(); value_ = nullptr; } +private: + T* value_ = nullptr; +}; + +std::wstring widen(const std::string& value) { + if (value.empty()) return {}; + const int count = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + value.data(), static_cast(value.size()), + nullptr, 0); + if (count <= 0) return {}; + std::wstring output(static_cast(count), L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(), + static_cast(value.size()), output.data(), count); + return output; +} + +std::string narrow(const std::wstring& value) { + if (value.empty()) return {}; + const int count = WideCharToMultiByte(CP_UTF8, 0, value.data(), + static_cast(value.size()), nullptr, 0, + nullptr, nullptr); + std::string output(static_cast(count), '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), + output.data(), count, nullptr, nullptr); + return output; +} + +std::string hresult_message(const char* operation, HRESULT hr) { + wchar_t* buffer = nullptr; + FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | + FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, static_cast(hr), 0, + reinterpret_cast(&buffer), 0, nullptr); + std::ostringstream message; + message << operation << " failed (0x" << std::hex << static_cast(hr) << ')'; + if (buffer) { + std::wstring detail(buffer); + LocalFree(buffer); + while (!detail.empty() && (detail.back() == L'\r' || detail.back() == L'\n')) { + detail.pop_back(); + } + message << ": " << narrow(detail); + } + return message.str(); +} + +dialog_result failure(const char* operation, HRESULT hr) { + return {dialog_status::error, {}, hresult_message(operation, hr)}; +} + +bool add_shell_item_path(IShellItem* item, dialog_result& result) { + wchar_t* path = nullptr; + const HRESULT hr = item->GetDisplayName(SIGDN_FILESYSPATH, &path); + if (FAILED(hr)) { + result = failure("Reading selected path", hr); + return false; + } + result.paths.emplace_back(path); + CoTaskMemFree(path); + return true; +} + +dialog_result show_dialog(const dialog_options& options, bool save, bool folder) { + const HRESULT init_hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + if (init_hr == RPC_E_CHANGED_MODE) { + // IFileDialog is an apartment-threaded COM component. If the caller + // already initialized this thread as MTA, host the modal dialog on a + // temporary STA instead of disturbing the caller's COM configuration. + dialog_result threaded_result; + std::thread dialog_thread([&] { threaded_result = show_dialog(options, save, folder); }); + dialog_thread.join(); + return threaded_result; + } + const bool uninitialize = SUCCEEDED(init_hr); + if (FAILED(init_hr)) { + return failure("COM initialization", init_hr); + } + + dialog_result result; + HRESULT hr = S_OK; + com_ptr dialog; + if (save) { + hr = CoCreateInstance(CLSID_FileSaveDialog, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(dialog.put())); + } else { + hr = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(dialog.put())); + } + if (FAILED(hr)) { + result = failure("Creating file dialog", hr); + } else { + FILEOPENDIALOGOPTIONS flags = 0; + hr = dialog->GetOptions(&flags); + if (SUCCEEDED(hr)) { + flags |= FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST; + if (!save && !folder) flags |= FOS_FILEMUSTEXIST; + if (folder) flags |= FOS_PICKFOLDERS; + if (!save && options.allow_multiple) flags |= FOS_ALLOWMULTISELECT; + if (options.show_hidden) flags |= FOS_FORCESHOWHIDDEN; + hr = dialog->SetOptions(flags); + } + + const auto title = widen(options.title); + if (SUCCEEDED(hr) && !title.empty()) hr = dialog->SetTitle(title.c_str()); + + std::vector names; + std::vector patterns; + std::vector specs; + names.reserve(options.filters.size()); + patterns.reserve(options.filters.size()); + for (const auto& filter : options.filters) { + names.push_back(widen(filter.name)); + std::wstring joined; + for (const auto& pattern : filter.patterns) { + if (!joined.empty()) joined += L';'; + joined += widen(pattern); + } + patterns.push_back(std::move(joined)); + } + specs.reserve(names.size()); + for (std::size_t i = 0; i < names.size(); ++i) { + specs.push_back({names[i].c_str(), patterns[i].c_str()}); + } + if (SUCCEEDED(hr) && !specs.empty()) { + hr = dialog->SetFileTypes(static_cast(specs.size()), specs.data()); + } + + if (SUCCEEDED(hr) && !options.initial_path.empty()) { + std::filesystem::path folder_path = options.initial_path; + std::error_code ec; + const bool initial_is_directory = std::filesystem::is_directory(folder_path, ec); + if (!initial_is_directory) folder_path = folder_path.parent_path(); + if (!folder_path.empty()) { + com_ptr initial; + const auto native = folder_path.wstring(); + if (SUCCEEDED(SHCreateItemFromParsingName(native.c_str(), nullptr, + IID_PPV_ARGS(initial.put())))) { + dialog->SetFolder(initial.get()); + } + } + if (save && !initial_is_directory && options.initial_path.has_filename()) { + dialog->SetFileName(options.initial_path.filename().wstring().c_str()); + } + } + + if (SUCCEEDED(hr)) hr = dialog->Show(nullptr); + if (hr == HRESULT_FROM_WIN32(ERROR_CANCELLED)) { + result.status = dialog_status::cancelled; + } else if (FAILED(hr)) { + result = failure("Showing file dialog", hr); + } else if (!save && options.allow_multiple) { + com_ptr open_dialog; + hr = dialog->QueryInterface(IID_PPV_ARGS(open_dialog.put())); + com_ptr items; + if (SUCCEEDED(hr)) hr = open_dialog->GetResults(items.put()); + DWORD count = 0; + if (SUCCEEDED(hr)) hr = items->GetCount(&count); + result.status = dialog_status::selected; + for (DWORD i = 0; SUCCEEDED(hr) && i < count; ++i) { + com_ptr item; + hr = items->GetItemAt(i, item.put()); + if (SUCCEEDED(hr) && !add_shell_item_path(item.get(), result)) hr = E_FAIL; + } + if (FAILED(hr) && result.status != dialog_status::error) result = failure("Reading selections", hr); + } else { + com_ptr item; + hr = dialog->GetResult(item.put()); + if (FAILED(hr)) result = failure("Reading selection", hr); + else { + result.status = dialog_status::selected; + add_shell_item_path(item.get(), result); + } + } + } + + dialog.reset(); + if (uninitialize) CoUninitialize(); + return result; +} + +bool shell_launch(const wchar_t* verb, const std::filesystem::path& target, + const wchar_t* parameters, std::string* error_message) { + const auto result = reinterpret_cast( + ShellExecuteW(nullptr, verb, target.c_str(), parameters, nullptr, SW_SHOWNORMAL)); + if (result > 32) return true; + if (error_message) { + *error_message = "Shell launch failed with code " + std::to_string(result); + } + return false; +} + +} // namespace + +dialog_result open_file(const dialog_options& options) { return show_dialog(options, false, false); } +dialog_result save_file(const dialog_options& options) { return show_dialog(options, true, false); } +dialog_result pick_folder(const dialog_options& options) { return show_dialog(options, false, true); } + +bool open_path(const std::filesystem::path& path, std::string* error_message) { + return shell_launch(L"open", path, nullptr, error_message); +} + +bool reveal_in_file_manager(const std::filesystem::path& path, std::string* error_message) { + const std::wstring parameters = L"/select,\"" + path.wstring() + L"\""; + return shell_launch(L"open", L"explorer.exe", parameters.c_str(), error_message); +} + +} // namespace izo