feat(dialogs): add cross-platform filesystem dialogs

This commit is contained in:
2026-06-18 00:49:32 -05:00
parent 2a226c3c29
commit 88eb3ced5b
9 changed files with 638 additions and 1 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,9 @@
# ---> C++
# CMake build trees
build/
cmake-build-*/
CMakeUserPresets.json
# Prerequisites
*.d

70
CMakeLists.txt Normal file
View File

@@ -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
$<$<PLATFORM_ID:Windows>:src/dialogs_windows.cpp>
$<$<PLATFORM_ID:Linux>:src/dialogs_linux.cpp>
)
target_include_directories(izo
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
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()

View File

@@ -1,3 +1,49 @@
# iZo
OS IO Interface.
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/dialogs.hpp>
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.
}
```

3
cmake/iZoConfig.cmake.in Normal file
View File

@@ -0,0 +1,3 @@
@PACKAGE_INIT@
include("${CMAKE_CURRENT_LIST_DIR}/iZoTargets.cmake")

23
examples/dialogs.cpp Normal file
View File

@@ -0,0 +1,23 @@
#include <izo/dialogs.hpp>
#include <iostream>
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;
}

50
include/izo/dialogs.hpp Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <filesystem>
#include <string>
#include <vector>
namespace izo {
enum class dialog_status {
selected,
cancelled,
error,
};
struct file_filter {
std::string name;
// Glob patterns, for example {"*.png", "*.jpg"}.
std::vector<std::string> patterns;
};
struct dialog_options {
std::string title;
std::filesystem::path initial_path;
std::vector<file_filter> filters;
bool allow_multiple = false;
bool show_hidden = false;
};
struct dialog_result {
dialog_status status = dialog_status::cancelled;
std::vector<std::filesystem::path> 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

5
src/dialogs.cpp Normal file
View File

@@ -0,0 +1,5 @@
#include <izo/dialogs.hpp>
// 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.

204
src/dialogs_linux.cpp Normal file
View File

@@ -0,0 +1,204 @@
#include <izo/dialogs.hpp>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <cstdlib>
#include <sstream>
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<std::string>& 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<char*> argv;
argv.reserve(arguments.size() + 1);
for (const auto& argument : arguments) argv.push_back(const_cast<char*>(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<std::size_t>(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<std::string> 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<std::string> 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<std::string>& 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<char>(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

231
src/dialogs_windows.cpp Normal file
View File

@@ -0,0 +1,231 @@
#include <izo/dialogs.hpp>
#include <windows.h>
#include <shobjidl.h>
#include <shellapi.h>
#include <sstream>
#include <thread>
#include <utility>
namespace izo {
namespace {
template <typename T>
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<int>(value.size()),
nullptr, 0);
if (count <= 0) return {};
std::wstring output(static_cast<std::size_t>(count), L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value.data(),
static_cast<int>(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<int>(value.size()), nullptr, 0,
nullptr, nullptr);
std::string output(static_cast<std::size_t>(count), '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(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<DWORD>(hr), 0,
reinterpret_cast<wchar_t*>(&buffer), 0, nullptr);
std::ostringstream message;
message << operation << " failed (0x" << std::hex << static_cast<unsigned long>(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<IFileDialog> 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<std::wstring> names;
std::vector<std::wstring> patterns;
std::vector<COMDLG_FILTERSPEC> 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<UINT>(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<IShellItem> 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<IFileOpenDialog> open_dialog;
hr = dialog->QueryInterface(IID_PPV_ARGS(open_dialog.put()));
com_ptr<IShellItemArray> 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<IShellItem> 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<IShellItem> 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<std::intptr_t>(
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