diff --git a/.gitignore b/.gitignore index 077920f..e7168d2 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ __pycache__/ *.egg-info # Stubs that get generated as part of the build process -src/polyscope_bindings/**.pyi +src/polyscope_bindings/**/*.pyi # Editor and OS things imgui.ini diff --git a/CMakeLists.txt b/CMakeLists.txt index c7679ff..3f8d890 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,7 @@ nanobind_add_module(polyscope_bindings # ImGui related things src/cpp/imgui/imgui.cpp + src/cpp/imgui/imgui_file_dialog.cpp src/cpp/imgui/imgui_enums.cpp src/cpp/imgui/imgui_structs.cpp @@ -137,15 +138,19 @@ nanobind_add_stub( OUTPUT_PATH . OUTPUT __init__.pyi - imgui.pyi + imgui/__init__.pyi + imgui/file_dialog.pyi implot.pyi ) # Copy stub files into the appropriate place in the source directory after build -set(STUB_FILES __init__.pyi imgui.pyi implot.pyi) +set(STUB_FILES __init__.pyi imgui/__init__.pyi imgui/file_dialog.pyi implot.pyi) foreach(STUB_FILE ${STUB_FILES}) + get_filename_component(STUB_DIR ${STUB_FILE} DIRECTORY) add_custom_command( TARGET polyscope_stub POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory + ${CMAKE_CURRENT_SOURCE_DIR}/src/polyscope_bindings/${STUB_DIR} COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_BINARY_DIR}/polyscope_bindings/${STUB_FILE} ${CMAKE_CURRENT_SOURCE_DIR}/src/polyscope_bindings/${STUB_FILE} @@ -154,4 +159,4 @@ foreach(STUB_FILE ${STUB_FILES}) endforeach() -install(TARGETS polyscope_bindings LIBRARY DESTINATION .) \ No newline at end of file +install(TARGETS polyscope_bindings LIBRARY DESTINATION .) diff --git a/deps/polyscope b/deps/polyscope index dcbaedb..e7a4371 160000 --- a/deps/polyscope +++ b/deps/polyscope @@ -1 +1 @@ -Subproject commit dcbaedb6cef9467e8f037dab7c89c116b0fc04dc +Subproject commit e7a43718402802725b5fee4b17491b305a327755 diff --git a/src/cpp/imgui/imgui.cpp b/src/cpp/imgui/imgui.cpp index 20f21c1..d8e82a2 100644 --- a/src/cpp/imgui/imgui.cpp +++ b/src/cpp/imgui/imgui.cpp @@ -15,6 +15,7 @@ void bind_imgui_io(nb::module_& m); void bind_imgui_style(nb::module_& m); void bind_imgui_drawlist(nb::module_& m); void bind_imgui_fonts(nb::module_& m); +void bind_imgui_file_dialog(nb::module_& m); void bind_imgui_api_main(nb::module_& m); void bind_imgui_api_context_creation(nb::module_& m); @@ -75,6 +76,7 @@ void bind_imgui(nb::module_& m) { bind_imgui_style(imgui_module); bind_imgui_drawlist(imgui_module); bind_imgui_fonts(imgui_module); + bind_imgui_file_dialog(imgui_module); bind_imgui_api_main(imgui_module); bind_imgui_api_context_creation(imgui_module); @@ -125,4 +127,4 @@ void bind_imgui(nb::module_& m) { bind_imgui_api_settings(imgui_module); bind_imgui_api_debug(imgui_module); bind_imgui_api_allocators(imgui_module); -} \ No newline at end of file +} diff --git a/src/cpp/imgui/imgui_file_dialog.cpp b/src/cpp/imgui/imgui_file_dialog.cpp new file mode 100644 index 0000000..f04cc96 --- /dev/null +++ b/src/cpp/imgui/imgui_file_dialog.cpp @@ -0,0 +1,178 @@ +#include "ImGuiFileDialog.h" + +#include "imgui_utils.h" + + +namespace nb = nanobind; +using namespace nb::literals; + + +/// Resolve the sentinel default to half the viewport, since ImGuiFileDialog's default minimum size +/// allows the window to collapse +ImVec2 resolve_min_size(const Vec2T& min_size) { + if (std::get<0>(min_size) < 0.0f && std::get<1>(min_size) < 0.0f) { + const ImVec2 viewport_size = ImGui::GetMainViewport()->Size; + return ImVec2(viewport_size.x * 0.5f, viewport_size.y * 0.5f); + } + return ImVec2(std::get<0>(min_size), std::get<1>(min_size)); +} + +void bind_imgui_file_dialog(nb::module_& imgui_module) { + auto m = imgui_module.def_submodule("file_dialog", "ImGuiFileDialog bindings"); + + // Configuration value passed to OpenDialog(). Pointer and callback fields are intentionally omitted. + nb::class_(m, "FileDialogConfig") + .def( + nb::new_([](const std::string& path, const std::string& file_name, + const std::string& file_path_name, int32_t count_selection_max, + ImGuiFileDialogFlags flags, float side_pane_width) { + IGFD::FileDialogConfig config; + config.path = path; + config.fileName = file_name; + config.filePathName = file_path_name; + config.countSelectionMax = count_selection_max; + config.flags = flags; + config.sidePaneWidth = side_pane_width; + return config; + }), + nb::kw_only(), nb::arg("path") = "", nb::arg("fileName") = "", + nb::arg("filePathName") = "", nb::arg("countSelectionMax") = 1, + nb::arg("flags") = static_cast(ImGuiFileDialogFlags_None), + nb::arg("sidePaneWidth") = 250.0f) + .def_rw("path", &IGFD::FileDialogConfig::path) + .def_rw("fileName", &IGFD::FileDialogConfig::fileName) + .def_rw("filePathName", &IGFD::FileDialogConfig::filePathName) + .def_rw("countSelectionMax", &IGFD::FileDialogConfig::countSelectionMax) + .def_rw("flags", &IGFD::FileDialogConfig::flags) + .def_rw("sidePaneWidth", &IGFD::FileDialogConfig::sidePaneWidth); + + // Ordinary API backed by FileDialog::Instance(), so callers do not need to retain a dialog object. + m.def( + "OpenDialog", + [](const std::string& key, const std::string& title, + const std::optional& filters, const std::optional& config) { + IGFD::FileDialog::Instance()->OpenDialog(key, title, filters ? filters->c_str() : nullptr, + config.value_or(IGFD::FileDialogConfig{})); + }, + nb::arg("key"), nb::arg("title"), nb::arg("filters").none(), + nb::arg("config").none() = nb::none()); + m.def( + "Display", + [](const std::string& key, ImGuiWindowFlags flags, const Vec2T& min_size, const Vec2T& max_size) { + return IGFD::FileDialog::Instance()->Display(key, flags, resolve_min_size(min_size), + ImVec2(std::get<0>(max_size), std::get<1>(max_size))); + }, + nb::arg("key"), nb::arg("flags") = static_cast(ImGuiWindowFlags_NoCollapse), + nb::arg("min_size") = Vec2T{-1.0f, -1.0f}, nb::arg("max_size") = Vec2T{FLT_MAX, FLT_MAX}); + m.def("Close", []() { IGFD::FileDialog::Instance()->Close(); }); + m.def( + "IsOpened", + [](const std::optional& key) { + return key ? IGFD::FileDialog::Instance()->IsOpened(*key) : IGFD::FileDialog::Instance()->IsOpened(); + }, + nb::arg("key").none() = nb::none()); + m.def("GetOpenedKey", []() { return IGFD::FileDialog::Instance()->GetOpenedKey(); }); + m.def("IsOk", []() { return IGFD::FileDialog::Instance()->IsOk(); }); + m.def( + "GetSelection", + [](IGFD_ResultMode mode) { + nb::dict selection; + for (const auto& [filename, path] : IGFD::FileDialog::Instance()->GetSelection(mode)) { + selection[nb::str(filename.c_str())] = nb::str(path.c_str()); + } + return selection; + }, + nb::arg("mode") = static_cast(IGFD_ResultMode_KeepInputFile)); + m.def("GetFilePathName", [](IGFD_ResultMode mode) { + return IGFD::FileDialog::Instance()->GetFilePathName(mode); + }, nb::arg("mode") = static_cast(IGFD_ResultMode_AddIfNoFileExt)); + m.def("GetCurrentFileName", [](IGFD_ResultMode mode) { + return IGFD::FileDialog::Instance()->GetCurrentFileName(mode); + }, nb::arg("mode") = static_cast(IGFD_ResultMode_AddIfNoFileExt)); + m.def("GetCurrentPath", []() { return IGFD::FileDialog::Instance()->GetCurrentPath(); }); + m.def("GetCurrentFilter", []() { return IGFD::FileDialog::Instance()->GetCurrentFilter(); }); + + // Advanced API for independently owned dialogs, plus access to the upstream library-owned singleton. + auto file_dialog_class = nb::class_(m, "FileDialog"); + file_dialog_class + .def(nb::init<>()) + .def_static("Instance", []() { return IGFD::FileDialog::Instance(); }, nb::rv_policy::reference) + .def( + "OpenDialog", + [](IGFD::FileDialog& dialog, const std::string& key, const std::string& title, + const std::optional& filters, const std::optional& config) { + dialog.OpenDialog(key, title, filters ? filters->c_str() : nullptr, + config.value_or(IGFD::FileDialogConfig{})); + }, + nb::arg("key"), nb::arg("title"), nb::arg("filters").none(), + nb::arg("config").none() = nb::none()) + .def( + "Display", + [](IGFD::FileDialog& dialog, const std::string& key, ImGuiWindowFlags flags, + const Vec2T& min_size, const Vec2T& max_size) { + return dialog.Display(key, flags, resolve_min_size(min_size), + ImVec2(std::get<0>(max_size), std::get<1>(max_size))); + }, + nb::arg("key"), nb::arg("flags") = static_cast(ImGuiWindowFlags_NoCollapse), + nb::arg("min_size") = Vec2T{-1.0f, -1.0f}, nb::arg("max_size") = Vec2T{FLT_MAX, FLT_MAX}) + .def("Close", &IGFD::FileDialog::Close) + .def("IsOpened", nb::overload_cast<>(&IGFD::FileDialog::IsOpened, nb::const_)) + .def("IsOpened", nb::overload_cast(&IGFD::FileDialog::IsOpened, nb::const_), + nb::arg("key")) + .def("GetOpenedKey", &IGFD::FileDialog::GetOpenedKey) + .def("IsOk", &IGFD::FileDialog::IsOk) + .def( + "GetSelection", + [](IGFD::FileDialog& dialog, IGFD_ResultMode mode) { + nb::dict selection; + for (const auto& [filename, path] : dialog.GetSelection(mode)) { + selection[nb::str(filename.c_str())] = nb::str(path.c_str()); + } + return selection; + }, + nb::arg("mode") = static_cast(IGFD_ResultMode_KeepInputFile)) + .def("GetFilePathName", &IGFD::FileDialog::GetFilePathName, + nb::arg("mode") = static_cast(IGFD_ResultMode_AddIfNoFileExt)) + .def("GetCurrentFileName", &IGFD::FileDialog::GetCurrentFileName, + nb::arg("mode") = static_cast(IGFD_ResultMode_AddIfNoFileExt)) + .def("GetCurrentPath", &IGFD::FileDialog::GetCurrentPath) + .def("GetCurrentFilter", &IGFD::FileDialog::GetCurrentFilter); + + { + auto e = nb::enum_(m, "ImGuiFileDialogFlags_", nb::is_arithmetic(), + nb::is_flag()); +#define BIND_IGFD_ENUM_VALUE(name) \ + e.value(#name, name); \ + m.attr(#name) = ImGuiFileDialogFlags_::name + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_None); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_ConfirmOverwrite); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_DontShowHiddenFiles); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_DisableCreateDirectoryButton); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_HideColumnType); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_HideColumnSize); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_HideColumnDate); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_NoDialog); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_ReadOnlyFileNameField); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_CaseInsensitiveExtentionFiltering); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_Modal); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_DisableThumbnailMode); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_DisablePlaceMode); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_DisableQuickPathSelection); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_ShowDevicesButton); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_NaturalSorting); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_OptionalFileName); + BIND_IGFD_ENUM_VALUE(ImGuiFileDialogFlags_Default); +#undef BIND_IGFD_ENUM_VALUE + } + + { + auto e = nb::enum_(m, "IGFD_ResultMode_", nb::is_arithmetic(), nb::is_flag()); +#define BIND_IGFD_ENUM_VALUE(name) \ + e.value(#name, name); \ + m.attr(#name) = IGFD_ResultMode_::name + BIND_IGFD_ENUM_VALUE(IGFD_ResultMode_AddIfNoFileExt); + BIND_IGFD_ENUM_VALUE(IGFD_ResultMode_OverwriteFileExt); + BIND_IGFD_ENUM_VALUE(IGFD_ResultMode_KeepInputFile); +#undef BIND_IGFD_ENUM_VALUE + } +} diff --git a/src/polyscope/imgui/__init__.py b/src/polyscope/imgui/__init__.py index 107a524..b346e15 100644 --- a/src/polyscope/imgui/__init__.py +++ b/src/polyscope/imgui/__init__.py @@ -1 +1,2 @@ from polyscope_bindings.imgui import * # noqa F403 +from polyscope_bindings.imgui import file_dialog as file_dialog diff --git a/test/tests/test_imgui.py b/test/tests/test_imgui.py index 50fa51c..6b8d50a 100644 --- a/test/tests/test_imgui.py +++ b/test/tests/test_imgui.py @@ -7,6 +7,45 @@ class TestImGuiBindings(unittest.TestCase): + def test_file_dialog_state(self): + file_dialog = psim.file_dialog + flags = file_dialog.ImGuiFileDialogFlags_Modal | file_dialog.ImGuiFileDialogFlags_ConfirmOverwrite + config = file_dialog.FileDialogConfig( + path=".", + fileName="mesh.obj", + filePathName="./mesh.obj", + countSelectionMax=3, + flags=flags, + sidePaneWidth=300.0, + ) + + self.assertEqual(config.path, ".") + self.assertEqual(config.fileName, "mesh.obj") + self.assertEqual(config.filePathName, "./mesh.obj") + self.assertEqual(config.countSelectionMax, 3) + self.assertEqual(config.flags, flags) + self.assertEqual(config.sidePaneWidth, 300.0) + + dialog = file_dialog.FileDialog() + self.assertFalse(dialog.IsOpened()) + self.assertFalse(dialog.IsOpened("unused")) + self.assertEqual(dialog.GetOpenedKey(), "") + self.assertFalse(dialog.IsOk()) + + file_dialog.Close() + self.assertFalse(file_dialog.IsOpened()) + self.addCleanup(file_dialog.Close) + file_dialog.OpenDialog("directory", "Choose directory", None) + self.assertTrue(file_dialog.IsOpened()) + self.assertTrue(file_dialog.IsOpened("directory")) + self.assertEqual(file_dialog.GetOpenedKey(), "directory") + self.assertFalse(file_dialog.IsOk()) + singleton = file_dialog.FileDialog.Instance() + self.assertIs(singleton, file_dialog.FileDialog.Instance()) + self.assertTrue(singleton.IsOpened("directory")) + file_dialog.Close() + self.assertFalse(file_dialog.IsOpened()) + def test_context_creation(self): # Test context creation functions using capsules # Note: We don't actually switch contexts in this test, just verify the bindings work