diff --git a/CMakeLists.txt b/CMakeLists.txt index 6c6a4aa..8fb5d91 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -126,12 +126,22 @@ endif() elseif(TARGET wxmono) target_link_libraries(csv_explorer PRIVATE wxmono) elseif(TARGET wx::wxcore AND TARGET wx::wxbase) - target_link_libraries(csv_explorer PRIVATE wx::wxcore wx::wxbase) + set(_wx_widgets_targets wx::wxbase wx::wxcore) + if(TARGET wx::wxadv) + list(APPEND _wx_widgets_targets wx::wxadv) + endif() + if(TARGET wx::wxaui) + list(APPEND _wx_widgets_targets wx::wxaui) + endif() + target_link_libraries(csv_explorer PRIVATE ${_wx_widgets_targets}) elseif(TARGET wx::core AND TARGET wx::base) set(_wx_widgets_targets wx::base wx::core) if(TARGET wx::adv) list(APPEND _wx_widgets_targets wx::adv) endif() + if(TARGET wx::aui) + list(APPEND _wx_widgets_targets wx::aui) + endif() if(TARGET wxWidgets::wxWidgets) target_link_libraries(csv_explorer PRIVATE wxWidgets::wxWidgets) else() @@ -149,7 +159,7 @@ endif() endif() endif() else() - find_package(wxWidgets REQUIRED COMPONENTS core base adv) + find_package(wxWidgets REQUIRED COMPONENTS core base adv aui) include(${wxWidgets_USE_FILE}) target_link_libraries(csv_explorer PRIVATE ${wxWidgets_LIBRARIES}) endif() diff --git a/src/main.cpp b/src/main.cpp index eb44551..481303c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,25 +6,25 @@ class CsvExplorerApp : public wxApp { public: bool OnInit() override { wxInitAllImageHandlers(); + SetExitOnFrameDelete(false); - wxString initialFile; - if (argc > 1) { - initialFile = wxString(argv[1]); - } - - auto* frame = CreateMainFrame(initialFile); + auto* frame = CreateMainFrame(argc > 1 ? wxString(argv[1]) : wxString()); SetTopWindow(frame); frame->Show(); + + for (int i = 2; i < argc; ++i) { + OpenFileInMainFrame(wxDynamicCast(wxGetActiveWindow(), wxFrame), wxString(argv[i])); + } + return true; } #ifdef __WXOSX__ void MacOpenFile(const wxString& fileName) override { - wxFrame* frame = wxDynamicCast(GetTopWindow(), wxFrame); + wxFrame* frame = wxDynamicCast(wxGetActiveWindow(), wxFrame); if (!frame) { - return; + frame = wxDynamicCast(GetTopWindow(), wxFrame); } - OpenFileInMainFrame(frame, fileName); } #endif diff --git a/src/main_frame.cpp b/src/main_frame.cpp index 6262c6e..33eb44f 100644 --- a/src/main_frame.cpp +++ b/src/main_frame.cpp @@ -1,11 +1,15 @@ #include -#include -#include -#include #include #include +#include #include +#include #include +#include +#ifdef __WXOSX__ +#include +#endif +#include #include #include @@ -21,7 +25,10 @@ namespace { enum { - ID_FIND_NEXT = wxID_HIGHEST + 1, + ID_NEW_TAB = wxID_HIGHEST + 1, + ID_CLOSE_TAB, + ID_CLOSE_WINDOW, + ID_FIND_NEXT, ID_FIND_PREVIOUS, ID_GO_TO_FIRST, ID_GO_TO_LAST, @@ -55,6 +62,7 @@ std::vector ParseCsvLine(const wxString& line) { field += ch; } } + fields.push_back(field); return fields; } @@ -75,6 +83,7 @@ bool ParseCsvFile(const wxString& path, std::vector& outHeaders, std:: } outHeaders = ParseCsvLine(headerLine); } + for (size_t i = 1; i < lineCount; ++i) { wxString line = file.GetLine(i); if (!line.empty() && line.Last() == '\r') { @@ -82,6 +91,7 @@ bool ParseCsvFile(const wxString& path, std::vector& outHeaders, std:: } outRows.push_back(ParseCsvLine(line)); } + return true; } @@ -89,9 +99,11 @@ bool ContainsText(const wxString& source, const wxString& target, bool matchCase if (target.IsEmpty()) { return false; } + if (matchCase) { return source.Find(target) != wxNOT_FOUND; } + return source.Lower().Find(target.Lower()) != wxNOT_FOUND; } @@ -114,6 +126,7 @@ wxString EscapeCsvField(const wxString& value) { if (needsQuotes) { return "\"" + escaped + "\""; } + return escaped; } @@ -130,1255 +143,1745 @@ wxString BuildCsvLine(const std::vector& fields) { } // namespace +class EditorPage; + class MainFrame : public wxFrame { public: - explicit MainFrame(const wxString& initialFile) - : wxFrame(nullptr, wxID_ANY, CSV_EXPLORER_NAME, wxDefaultPosition, wxSize(900, 600)) { - SetDropTarget(new CsvFileDropTarget(*this)); - BuildMenuBar(); - BuildAccelerators(); - BuildGrid(); - BuildStatusBar(); - ApplyWindowIcon(); - if (!initialFile.IsEmpty()) { - OpenDocumentFile(initialFile); - } else { - CreateNewFile(); - } - } + explicit MainFrame(const wxString& initialFile); - bool OpenDocumentFile(const wxString& path) { - if (!ConfirmDirtyFileAction()) { - return false; + bool OpenDocumentPath(const wxString& path); + bool OpenDroppedFiles(EditorPage* preferredPage, const wxArrayString& filenames); + void NotifyPageStateChanged(EditorPage* page); + void ResizeToContentOnce(EditorPage* page); + EditorPage* GetActivePage() const; + +private: + class FrameFileDropTarget final : public wxFileDropTarget { + public: + explicit FrameFileDropTarget(MainFrame& frame) + : m_frame(frame) {} + + bool OnDropFiles(wxCoord, wxCoord, const wxArrayString& filenames) override { + return m_frame.OpenDroppedFiles(m_frame.GetActivePage(), filenames); } - OpenFile(path); - return !m_currentFile.IsEmpty() && m_currentFile == path; + private: + MainFrame& m_frame; + }; + + void BuildMenuBar(); + void BuildAccelerators(); + void BuildNotebook(); + void BuildStatusBar(); + void ApplyWindowIcon(); + void UpdateFrameTitle(); + void UpdateStatusBar(); + bool ConfirmCloseAllPages(); + bool ClosePage(EditorPage* page); + EditorPage* CreateBlankTab(bool activate, bool startEditingHeader); + bool OpenPathInPreferredPage(EditorPage* preferredPage, const wxString& path); + + void OnNewWindow(wxCommandEvent&); + void OnNewTab(wxCommandEvent&); + void OnCloseWindow(wxCommandEvent&); + void OnCloseTab(wxCommandEvent&); + void OnOpen(wxCommandEvent&); + void OnSave(wxCommandEvent&); + void OnSaveAs(wxCommandEvent&); + void OnPrintPreview(wxCommandEvent&); + void OnPrint(wxCommandEvent&); + void OnExit(wxCommandEvent&); + void OnCopy(wxCommandEvent&); + void OnGoToFirst(wxCommandEvent&); + void OnGoToLast(wxCommandEvent&); + void OnGoToRow(wxCommandEvent&); + void OnFind(wxCommandEvent&); + void OnFindNext(wxCommandEvent&); + void OnFindPrevious(wxCommandEvent&); + void OnAbout(wxCommandEvent&); + void OnNotebookPageChanged(wxAuiNotebookEvent&); + void OnNotebookPageClose(wxAuiNotebookEvent&); + void OnActivate(wxActivateEvent&); + void OnClose(wxCloseEvent&); + + wxAuiNotebook* m_notebook{nullptr}; + bool m_hasAutoResizedToContent{false}; +}; + +class EditorPage : public wxPanel { +public: + friend class MainFrame; + + EditorPage(MainFrame& owner, wxWindow* parent); + ~EditorPage() override; + + bool OpenDocumentFile(const wxString& path); + bool SaveCurrentFile(); + bool SaveCurrentFileAs(); + bool ConfirmClose(); + void CreateBlankDocument(bool startEditingHeader); + bool IsEffectivelyEmptyDocument() const; + bool IsDirty() const { + return m_isDirty; + } + + wxString GetDisplayFileName() const; + wxString GetTabLabel() const; + PrintableDocument BuildPrintableDocument() const; + wxString GetStatusPrimaryText() const; + wxString GetStatusSecondaryText() const; + + void CopySelection(); + void GoToFirst(); + void GoToLast(); + void PromptGoToRow(); + void ShowFindDialog(); + void FindNext(); + void FindPrevious(); + void ShowPrintPreview(); + void ShowPrint(); + void FocusEditor(); + bool HasLoadedFile() const { + return !m_currentFile.IsEmpty(); } private: class CsvFileDropTarget final : public wxFileDropTarget { public: - explicit CsvFileDropTarget(MainFrame& frame) - : m_frame(frame) {} + explicit CsvFileDropTarget(EditorPage& page) + : m_page(page) {} bool OnDropFiles(wxCoord, wxCoord, const wxArrayString& filenames) override { - return m_frame.HandleDroppedFiles(filenames); + return m_page.HandleDroppedFiles(filenames); } private: - MainFrame& m_frame; + EditorPage& m_page; }; - void BuildMenuBar() { - auto* fileMenu = new wxMenu(); - fileMenu->Append(wxID_NEW, "&New\tCtrl+N"); - fileMenu->Append(wxID_OPEN, "&Open...\tCtrl+O"); - fileMenu->Append(wxID_SAVE, "&Save\tCtrl+S"); - fileMenu->Append(wxID_SAVEAS, "Save &As...\tCtrl+Shift+S"); - fileMenu->AppendSeparator(); - fileMenu->Append(wxID_PREVIEW, "Print Pre&view\tCtrl+Shift+P"); - fileMenu->Append(wxID_PRINT, "&Print...\tCtrl+P"); - fileMenu->AppendSeparator(); - fileMenu->Append(wxID_EXIT, "E&xit\tCtrl+Q"); - - auto* editMenu = new wxMenu(); - editMenu->Append(wxID_COPY, "&Copy\tCtrl+C"); - auto* insertMenu = new wxMenu(); - insertMenu->Append(ID_INSERT_ROW_BEFORE, "Insert row &before"); - insertMenu->Append(ID_INSERT_ROW_AFTER, "Insert row &after"); - insertMenu->AppendSeparator(); - insertMenu->Append(ID_INSERT_COLUMN_BEFORE, "Insert column &before"); - insertMenu->Append(ID_INSERT_COLUMN_AFTER, "Insert column &after"); - editMenu->AppendSubMenu(insertMenu, "&Insert"); - editMenu->AppendSeparator(); - auto* goToMenu = new wxMenu(); -#ifdef __WXOSX__ - goToMenu->Append(ID_GO_TO_FIRST, "Go to &First\tCtrl+Up"); - goToMenu->Append(ID_GO_TO_LAST, "Go to &Last\tCtrl+Down"); - goToMenu->Append(ID_GO_TO_ROW, "Go to &Row...\tCtrl+G"); -#else - goToMenu->Append(ID_GO_TO_FIRST, "Go to &First\tCtrl+Home"); - goToMenu->Append(ID_GO_TO_LAST, "Go to &Last\tCtrl+End"); - goToMenu->Append(ID_GO_TO_ROW, "Go to &Row...\tCtrl+G"); -#endif - editMenu->AppendSubMenu(goToMenu, "&Go To"); - editMenu->AppendSeparator(); - editMenu->Append(wxID_FIND, "&Find...\tCtrl+F"); - editMenu->Append(ID_FIND_NEXT, "Find &Next\tF3"); - editMenu->Append(ID_FIND_PREVIOUS, "Find &Previous\tShift+F3"); - - auto* helpMenu = new wxMenu(); - helpMenu->Append(wxID_ABOUT, "&About"); - - auto* bar = new wxMenuBar(); - bar->Append(fileMenu, "&File"); - bar->Append(editMenu, "&Edit"); - bar->Append(helpMenu, "&Help"); - SetMenuBar(bar); - } - - void BuildGrid() { - m_grid = new wxGrid(this, wxID_ANY); - m_grid->CreateGrid(0, 0); - m_grid->EnableEditing(true); - m_grid->EnableDragRowSize(false); - m_grid->EnableDragColSize(true); - m_grid->SetDefaultCellOverflow(false); - m_grid->SetRowLabelSize(0); - m_grid->SetColLabelAlignment(wxALIGN_LEFT, wxALIGN_CENTER); - - m_grid->Bind(wxEVT_GRID_CELL_LEFT_DCLICK, &MainFrame::OnCellLeftDClick, this); - m_grid->Bind(wxEVT_GRID_CELL_RIGHT_CLICK, &MainFrame::OnCellRightClick, this); - m_grid->Bind(wxEVT_GRID_LABEL_LEFT_DCLICK, &MainFrame::OnLabelLeftDClick, this); - m_grid->Bind(wxEVT_GRID_LABEL_RIGHT_CLICK, &MainFrame::OnLabelRightClick, this); - m_grid->Bind(wxEVT_GRID_SELECT_CELL, &MainFrame::OnSelectCell, this); - m_grid->Bind(wxEVT_GRID_CELL_CHANGED, &MainFrame::OnCellChanged, this); - m_grid->Bind(wxEVT_GRID_EDITOR_SHOWN, &MainFrame::OnEditorShown, this); - m_grid->Bind(wxEVT_CHAR_HOOK, &MainFrame::OnGridCharHook, this); - m_grid->Bind(wxEVT_SIZE, &MainFrame::OnGridResized, this); - m_grid->GetGridWindow()->Bind(wxEVT_LEFT_DCLICK, &MainFrame::OnGridWindowLeftDClick, this); - m_grid->SetDropTarget(new CsvFileDropTarget(*this)); - m_grid->GetGridWindow()->SetDropTarget(new CsvFileDropTarget(*this)); - - m_headerEditor = new wxTextCtrl( - m_grid->GetGridColLabelWindow(), - wxID_ANY, - {}, - wxDefaultPosition, - wxDefaultSize, - wxTE_PROCESS_ENTER); - m_headerEditor->Hide(); - m_headerEditor->Bind(wxEVT_TEXT_ENTER, &MainFrame::OnHeaderEditorEnter, this); - m_headerEditor->Bind(wxEVT_KILL_FOCUS, &MainFrame::OnHeaderEditorKillFocus, this); - m_headerEditor->Bind(wxEVT_CHAR_HOOK, &MainFrame::OnHeaderEditorCharHook, this); + void BuildGrid(); + void NotifyStateChanged(); + void ResizeOwnerToContent(); + unsigned int GetColumnCount() const; + void NormalizeRows(unsigned int minimumColumnCount); + void EnsureRowCount(int desiredRows); + void EnsureColumnCount(int desiredColumns); + wxString GetCellText(unsigned int row, unsigned int col) const; + void SetCellText(unsigned int row, unsigned int col, const wxString& value); + void RefreshGridFromData(); + int GetActiveRowIndex() const; + int GetActiveColumnIndex() const; + void GoToRow(int row); + wxRect GetHeaderRect(int col) const; + void RepositionHeaderEditor(); + void BeginHeaderEdit(int col); + void FocusFirstDataCellForEntry(); + void CommitHeaderEdit(); + void CancelHeaderEdit(); + void SyncCellFromGrid(int row, int col); + bool SaveToPath(const wxString& path); + void OpenFileInternal(const wxString& path); + bool HandleDroppedFiles(const wxArrayString& filenames); + wxString GetBlockText(int topRow, int leftCol, int bottomRow, int rightCol) const; + void CopyRow(int row); + void CopyCell(int row, int column); + void CopyToClipboard(const wxString& output); + void CommitActiveEdit(); + void AppendEmptyRow(); + void InsertColumn(int insertAt); + void InsertRow(int insertAt); + void MoveToNextEditableCell(); + void SelectCell(unsigned int row, unsigned int col); + bool FindInData(bool forward); + void SetDirty(bool dirty); + + void OnContextCopyRow(wxCommandEvent&); + void OnContextCopyCell(wxCommandEvent&); + void OnInsertColumnBefore(wxCommandEvent&); + void OnInsertColumnAfter(wxCommandEvent&); + void OnInsertRowBefore(wxCommandEvent&); + void OnInsertRowAfter(wxCommandEvent&); + void ShowContextMenu(const wxPoint& position); + void ShowHeaderContextMenu(const wxPoint& position); + void OnCellLeftDClick(wxGridEvent& event); + void OnCellRightClick(wxGridEvent& event); + void OnLabelLeftDClick(wxGridEvent& event); + void OnLabelRightClick(wxGridEvent& event); + void OnEditorShown(wxGridEvent& event); + void OnCellChanged(wxGridEvent& event); + void OnSelectCell(wxGridEvent& event); + void OnGridCharHook(wxKeyEvent& event); + void OnHeaderEditorEnter(wxCommandEvent&); + void OnHeaderEditorKillFocus(wxFocusEvent& event); + void OnHeaderEditorCharHook(wxKeyEvent& event); + void OnGridResized(wxSizeEvent& event); + void OnGridWindowLeftDClick(wxMouseEvent& event); + void OnFindDialog(wxFindDialogEvent& event); + void OnFindDialogClose(wxFindDialogEvent&); + + MainFrame& m_owner; + wxGrid* m_grid{nullptr}; + wxTextCtrl* m_headerEditor{nullptr}; + std::vector> m_rows; + std::vector m_headers; + wxString m_currentFile; + wxString m_documentName{"untitled.csv"}; + wxFindReplaceData m_findData{wxFR_DOWN}; + wxFindReplaceDialog* m_findDialog{nullptr}; + wxPrintData m_printData; + size_t m_lastFindIndex{0}; + bool m_lastFindValid{false}; + bool m_isDirty{false}; + bool m_isRefreshingGrid{false}; + int m_contextRow{-1}; + int m_contextColumn{-1}; + int m_activeHeaderColumn{-1}; +}; + +static MainFrame* CreateAndShowMainFrame(const wxString& initialFile) { + auto* frame = new MainFrame(initialFile); + frame->Show(); + frame->Raise(); + if (wxTheApp) { + wxTheApp->SetTopWindow(frame); + } + return frame; +} - auto* sizer = new wxBoxSizer(wxVERTICAL); - sizer->Add(m_grid, 1, wxEXPAND | wxALL, 0); - SetSizer(sizer); +static MainFrame* FindOtherMainFrame(const MainFrame* current) { + for (wxWindowList::compatibility_iterator node = wxTopLevelWindows.GetFirst(); node; node = node->GetNext()) { + auto* frame = dynamic_cast(node->GetData()); + if (frame && frame != current) { + return frame; + } } + return nullptr; +} - void BuildStatusBar() { - wxFrame::CreateStatusBar(2); - const int widths[] = { -1, FromDIP(180) }; - GetStatusBar()->SetStatusWidths(2, widths); - UpdateStatusBar(); +MainFrame::MainFrame(const wxString& initialFile) + : wxFrame(nullptr, wxID_ANY, CSV_EXPLORER_NAME, wxDefaultPosition, wxSize(900, 600)) { + SetDropTarget(new FrameFileDropTarget(*this)); + BuildMenuBar(); + BuildAccelerators(); + BuildNotebook(); + BuildStatusBar(); + ApplyWindowIcon(); + + EditorPage* page = CreateBlankTab(true, initialFile.IsEmpty()); + if (!initialFile.IsEmpty()) { + OpenPathInPreferredPage(page, initialFile); } +} + +void MainFrame::BuildMenuBar() { +#ifdef __WXOSX__ + wxMenuBar::SetAutoWindowMenu(false); +#endif - void BuildAccelerators() { - wxAcceleratorEntry entries[] = { - { wxACCEL_CTRL, 'N', wxID_NEW }, - { wxACCEL_CTRL, 'O', wxID_OPEN }, - { wxACCEL_CTRL, 'S', wxID_SAVE }, - { wxACCEL_CTRL | wxACCEL_SHIFT, 'S', wxID_SAVEAS }, - { wxACCEL_CTRL, 'P', wxID_PRINT }, - { wxACCEL_CTRL | wxACCEL_SHIFT, 'P', wxID_PREVIEW }, - { wxACCEL_CTRL, 'Q', wxID_EXIT }, - { wxACCEL_CTRL, 'C', wxID_COPY }, - { wxACCEL_CTRL, 'F', wxID_FIND }, - { wxACCEL_NORMAL, WXK_F3, ID_FIND_NEXT }, - { wxACCEL_SHIFT, WXK_F3, ID_FIND_PREVIOUS }, - { wxACCEL_CTRL, 'G', ID_GO_TO_ROW }, + auto* fileMenu = new wxMenu(); + fileMenu->Append(wxID_NEW, "&New...\tCtrl+N"); + fileMenu->Append(wxID_OPEN, "&Open...\tCtrl+O"); + fileMenu->AppendSeparator(); + fileMenu->Append(wxID_SAVE, "&Save\tCtrl+S"); + fileMenu->Append(wxID_SAVEAS, "Save &As...\tCtrl+Shift+S"); + fileMenu->AppendSeparator(); + fileMenu->Append(wxID_PREVIEW, "Print Pre&view\tCtrl+Shift+P"); + fileMenu->Append(wxID_PRINT, "&Print...\tCtrl+P"); + fileMenu->AppendSeparator(); + fileMenu->Append(wxID_EXIT, "E&xit\tCtrl+Q"); + + auto* editMenu = new wxMenu(); + editMenu->Append(wxID_COPY, "&Copy\tCtrl+C"); + auto* insertMenu = new wxMenu(); + insertMenu->Append(ID_INSERT_ROW_BEFORE, "Insert row &before"); + insertMenu->Append(ID_INSERT_ROW_AFTER, "Insert row &after"); + insertMenu->AppendSeparator(); + insertMenu->Append(ID_INSERT_COLUMN_BEFORE, "Insert column &before"); + insertMenu->Append(ID_INSERT_COLUMN_AFTER, "Insert column &after"); + editMenu->AppendSubMenu(insertMenu, "&Insert"); + editMenu->AppendSeparator(); + + auto* goToMenu = new wxMenu(); #ifdef __WXOSX__ - { wxACCEL_CTRL, WXK_UP, ID_GO_TO_FIRST }, - { wxACCEL_CTRL, WXK_DOWN, ID_GO_TO_LAST } + goToMenu->Append(ID_GO_TO_FIRST, "Go to &First\tCtrl+Up"); + goToMenu->Append(ID_GO_TO_LAST, "Go to &Last\tCtrl+Down"); + goToMenu->Append(ID_GO_TO_ROW, "Go to &Row...\tCtrl+G"); #else - { wxACCEL_CTRL, WXK_HOME, ID_GO_TO_FIRST }, - { wxACCEL_CTRL, WXK_END, ID_GO_TO_LAST } + goToMenu->Append(ID_GO_TO_FIRST, "Go to &First\tCtrl+Home"); + goToMenu->Append(ID_GO_TO_LAST, "Go to &Last\tCtrl+End"); + goToMenu->Append(ID_GO_TO_ROW, "Go to &Row...\tCtrl+G"); #endif - }; - const int entryCount = static_cast(sizeof(entries) / sizeof(entries[0])); - wxAcceleratorTable accelerators(entryCount, entries); - SetAcceleratorTable(accelerators); - } + editMenu->AppendSubMenu(goToMenu, "&Go To"); + editMenu->AppendSeparator(); + editMenu->Append(wxID_FIND, "&Find...\tCtrl+F"); + editMenu->Append(ID_FIND_NEXT, "Find &Next\tF3"); + editMenu->Append(ID_FIND_PREVIOUS, "Find &Previous\tShift+F3"); + + auto* windowMenu = new wxMenu(); + windowMenu->Append(wxID_NEW, "New &Window\tCtrl+N"); + windowMenu->Append(ID_CLOSE_WINDOW, "Close &Window\tCtrl+Shift+W"); + windowMenu->AppendSeparator(); + windowMenu->Append(ID_NEW_TAB, "New &Tab\tCtrl+T"); + windowMenu->Append(ID_CLOSE_TAB, "Close &Tab\tCtrl+W"); + + auto* helpMenu = new wxMenu(); + helpMenu->Append(wxID_ABOUT, "&About"); + + auto* bar = new wxMenuBar(); + bar->Append(fileMenu, "&File"); + bar->Append(editMenu, "&Edit"); +#ifdef __WXOSX__ + bar->Append(windowMenu, "Window"); +#else + bar->Append(windowMenu, "&Window"); +#endif + bar->Append(helpMenu, "&Help"); + SetMenuBar(bar); +} + +void MainFrame::BuildAccelerators() { + wxAcceleratorEntry entries[] = { + { wxACCEL_CTRL, 'N', wxID_NEW }, + { wxACCEL_CTRL, 'T', ID_NEW_TAB }, + { wxACCEL_CTRL, 'W', ID_CLOSE_TAB }, + { wxACCEL_CTRL | wxACCEL_SHIFT, 'W', ID_CLOSE_WINDOW }, + { wxACCEL_CTRL, 'O', wxID_OPEN }, + { wxACCEL_CTRL, 'S', wxID_SAVE }, + { wxACCEL_CTRL | wxACCEL_SHIFT, 'S', wxID_SAVEAS }, + { wxACCEL_CTRL, 'P', wxID_PRINT }, + { wxACCEL_CTRL | wxACCEL_SHIFT, 'P', wxID_PREVIEW }, + { wxACCEL_CTRL, 'Q', wxID_EXIT }, + { wxACCEL_CTRL, 'C', wxID_COPY }, + { wxACCEL_CTRL, 'F', wxID_FIND }, + { wxACCEL_NORMAL, WXK_F3, ID_FIND_NEXT }, + { wxACCEL_SHIFT, WXK_F3, ID_FIND_PREVIOUS }, + { wxACCEL_CTRL, 'G', ID_GO_TO_ROW }, +#ifdef __WXOSX__ + { wxACCEL_CTRL, WXK_UP, ID_GO_TO_FIRST }, + { wxACCEL_CTRL, WXK_DOWN, ID_GO_TO_LAST } +#else + { wxACCEL_CTRL, WXK_HOME, ID_GO_TO_FIRST }, + { wxACCEL_CTRL, WXK_END, ID_GO_TO_LAST } +#endif + }; + + SetAcceleratorTable(wxAcceleratorTable(static_cast(sizeof(entries) / sizeof(entries[0])), entries)); +} + +void MainFrame::BuildNotebook() { + const long notebookStyle = + wxAUI_NB_TOP | + wxAUI_NB_TAB_MOVE | + wxAUI_NB_SCROLL_BUTTONS | + wxAUI_NB_CLOSE_ON_ALL_TABS; + m_notebook = new wxAuiNotebook(this, wxID_ANY, wxDefaultPosition, wxDefaultSize, notebookStyle); + + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(m_notebook, 1, wxEXPAND | wxALL, 0); + SetSizer(sizer); + + Bind(wxEVT_MENU, &MainFrame::OnNewWindow, this, wxID_NEW); + Bind(wxEVT_MENU, &MainFrame::OnNewTab, this, ID_NEW_TAB); + Bind(wxEVT_MENU, &MainFrame::OnCloseWindow, this, ID_CLOSE_WINDOW); + Bind(wxEVT_MENU, &MainFrame::OnCloseTab, this, ID_CLOSE_TAB); + Bind(wxEVT_MENU, &MainFrame::OnOpen, this, wxID_OPEN); + Bind(wxEVT_MENU, &MainFrame::OnSave, this, wxID_SAVE); + Bind(wxEVT_MENU, &MainFrame::OnSaveAs, this, wxID_SAVEAS); + Bind(wxEVT_MENU, &MainFrame::OnPrintPreview, this, wxID_PREVIEW); + Bind(wxEVT_MENU, &MainFrame::OnPrint, this, wxID_PRINT); + Bind(wxEVT_MENU, &MainFrame::OnExit, this, wxID_EXIT); + Bind(wxEVT_MENU, &MainFrame::OnCopy, this, wxID_COPY); + Bind(wxEVT_MENU, &MainFrame::OnGoToFirst, this, ID_GO_TO_FIRST); + Bind(wxEVT_MENU, &MainFrame::OnGoToLast, this, ID_GO_TO_LAST); + Bind(wxEVT_MENU, &MainFrame::OnGoToRow, this, ID_GO_TO_ROW); + Bind(wxEVT_MENU, &MainFrame::OnFind, this, wxID_FIND); + Bind(wxEVT_MENU, &MainFrame::OnFindNext, this, ID_FIND_NEXT); + Bind(wxEVT_MENU, &MainFrame::OnFindPrevious, this, ID_FIND_PREVIOUS); + Bind(wxEVT_MENU, &MainFrame::OnAbout, this, wxID_ABOUT); + Bind(wxEVT_AUINOTEBOOK_PAGE_CHANGED, &MainFrame::OnNotebookPageChanged, this); + Bind(wxEVT_AUINOTEBOOK_PAGE_CLOSE, &MainFrame::OnNotebookPageClose, this); + Bind(wxEVT_ACTIVATE, &MainFrame::OnActivate, this); + Bind(wxEVT_CLOSE_WINDOW, &MainFrame::OnClose, this); +} - void ApplyWindowIcon() { +void MainFrame::BuildStatusBar() { + CreateStatusBar(2); + const int widths[] = { -1, FromDIP(180) }; + GetStatusBar()->SetStatusWidths(2, widths); + UpdateStatusBar(); +} + +void MainFrame::ApplyWindowIcon() { #ifdef __WXMSW__ - SetIcon(wxICON(IDI_APP_ICON)); - return; + SetIcon(wxICON(IDI_APP_ICON)); + return; #endif - wxIcon icon = LoadAppIcon(); - if (icon.IsOk()) { - SetIcon(icon); - } + wxIcon icon = LoadAppIcon(); + if (icon.IsOk()) { + SetIcon(icon); } +} - unsigned int GetColumnCount() const { - unsigned int columnCount = static_cast(m_headers.size()); - for (const auto& row : m_rows) { - columnCount = std::max(columnCount, static_cast(row.size())); - } - return columnCount; +void MainFrame::ResizeToContentOnce(EditorPage* page) { + if (m_hasAutoResizedToContent || !page || !page->HasLoadedFile()) { + return; } - void NormalizeRows(unsigned int minimumColumnCount) { - unsigned int columnCount = std::max(GetColumnCount(), minimumColumnCount); - m_headers.resize(columnCount); - for (auto& row : m_rows) { - row.resize(columnCount); - } - } + m_hasAutoResizedToContent = true; + page->ResizeOwnerToContent(); +} - void EnsureRowCount(int desiredRows) { - const int currentRows = m_grid->GetNumberRows(); - if (currentRows < desiredRows) { - m_grid->AppendRows(desiredRows - currentRows); - } else if (currentRows > desiredRows) { - m_grid->DeleteRows(0, currentRows - desiredRows); - } - } +EditorPage* MainFrame::CreateBlankTab(bool activate, bool startEditingHeader) { + auto* page = new EditorPage(*this, m_notebook); + const size_t pageIndex = static_cast(m_notebook->GetPageCount()); + m_notebook->AddPage(page, "untitled.csv", activate); + page->CreateBlankDocument(startEditingHeader); + NotifyPageStateChanged(page); - void EnsureColumnCount(int desiredColumns) { - const int currentColumns = m_grid->GetNumberCols(); - if (currentColumns < desiredColumns) { - m_grid->AppendCols(desiredColumns - currentColumns); - } else if (currentColumns > desiredColumns) { - m_grid->DeleteCols(desiredColumns, currentColumns - desiredColumns); - } + if (!activate && pageIndex < static_cast(m_notebook->GetPageCount())) { + m_notebook->SetSelection(m_notebook->GetSelection()); } - wxString GetCellText(unsigned int row, unsigned int col) const { - if (row < m_rows.size() && col < m_rows[row].size()) { - return m_rows[row][col]; - } - return {}; + return page; +} + +EditorPage* MainFrame::GetActivePage() const { + if (!m_notebook) { + return nullptr; } - void SetCellText(unsigned int row, unsigned int col, const wxString& value) { - if (row >= m_rows.size()) { - return; - } - if (col >= m_rows[row].size()) { - m_rows[row].resize(col + 1); - } - m_rows[row][col] = value; - if (row < static_cast(m_grid->GetNumberRows()) && col < static_cast(m_grid->GetNumberCols())) { - m_grid->SetCellValue(static_cast(row), static_cast(col), value); - } + const int selection = m_notebook->GetSelection(); + if (selection == wxNOT_FOUND) { + return nullptr; } - void RefreshGridFromData() { - const int rows = static_cast(m_rows.size()); - const int columns = static_cast(GetColumnCount()); - m_isRefreshingGrid = true; + return dynamic_cast(m_notebook->GetPage(static_cast(selection))); +} - EnsureColumnCount(columns); - EnsureRowCount(rows); +void MainFrame::NotifyPageStateChanged(EditorPage* page) { + if (!page || !m_notebook) { + return; + } - for (int col = 0; col < columns; ++col) { - wxString title = wxString::Format("Column %d", col + 1); - if (col < static_cast(m_headers.size()) && !m_headers[col].IsEmpty()) { - title = m_headers[col]; - } - m_grid->SetColLabelValue(col, title); - } + const int index = m_notebook->FindPage(page); + if (index != wxNOT_FOUND) { + m_notebook->SetPageText(static_cast(index), page->GetTabLabel()); + } - for (int row = 0; row < rows; ++row) { - for (int col = 0; col < columns; ++col) { - m_grid->SetCellValue(row, col, GetCellText(static_cast(row), static_cast(col))); - } - } + if (page == GetActivePage()) { + UpdateFrameTitle(); + UpdateStatusBar(); + } +} + +void MainFrame::UpdateFrameTitle() { + EditorPage* page = GetActivePage(); + const wxString label = page ? page->GetTabLabel() : "untitled.csv"; + SetTitle(wxString::Format("%s - %s", CSV_EXPLORER_NAME, label)); +} - m_isRefreshingGrid = false; - RepositionHeaderEditor(); +void MainFrame::UpdateStatusBar() { + if (!GetStatusBar()) { + return; } - void UpdateTitle() { - wxString title = wxString::Format("%s - %s", CSV_EXPLORER_NAME, GetDisplayFileName()); - if (m_isDirty) { - title += " *"; - } - SetTitle(title); + EditorPage* page = GetActivePage(); + if (!page) { + SetStatusText({}, 0); + SetStatusText({}, 1); + return; } - void UpdateStatusBar() { - if (!GetStatusBar()) { - return; - } + SetStatusText(page->GetStatusPrimaryText(), 0); + SetStatusText(page->GetStatusSecondaryText(), 1); +} - const int totalRows = static_cast(m_rows.size()); - const int selectedRow = m_grid ? m_grid->GetGridCursorRow() : -1; - wxString status = wxString::Format( - "Row %d of %d", - totalRows > 0 && selectedRow >= 0 ? selectedRow + 1 : 0, - totalRows); - SetStatusText(m_isDirty ? "Modified" : wxString(), 0); - SetStatusText(status, 1); +bool MainFrame::OpenPathInPreferredPage(EditorPage* preferredPage, const wxString& path) { + if (preferredPage && preferredPage->IsEffectivelyEmptyDocument()) { + m_notebook->SetSelection(static_cast(m_notebook->FindPage(preferredPage))); + return preferredPage->OpenDocumentFile(path); } - void SetDirty(bool dirty) { - if (m_isDirty == dirty) { - return; - } + CreateAndShowMainFrame(path); + return true; +} - m_isDirty = dirty; - UpdateTitle(); - UpdateStatusBar(); +bool MainFrame::OpenDocumentPath(const wxString& path) { + return OpenPathInPreferredPage(GetActivePage(), path); +} + +bool MainFrame::OpenDroppedFiles(EditorPage* preferredPage, const wxArrayString& filenames) { + if (filenames.empty()) { + return false; } - bool IsEffectivelyEmptyDocument() const { - if (!m_currentFile.IsEmpty()) { - return false; + bool openedAny = false; + bool firstFile = true; + for (const wxString& filename : filenames) { + if (firstFile) { + openedAny = OpenPathInPreferredPage(preferredPage, filename) || openedAny; + firstFile = false; + } else { + CreateAndShowMainFrame(filename); + openedAny = true; } + } - if (GetColumnCount() != 1 || m_headers.size() != 1) { - return false; - } + return openedAny; +} + +bool MainFrame::ConfirmCloseAllPages() { + if (!m_notebook) { + return true; + } - const wxString& header = m_headers[0]; - if (!header.IsEmpty() && header != "New column" && header != "Column 1") { + for (size_t i = 0; i < m_notebook->GetPageCount(); ++i) { + auto* page = dynamic_cast(m_notebook->GetPage(i)); + if (page && !page->ConfirmClose()) { return false; } + } - for (const auto& row : m_rows) { - for (const auto& cell : row) { - if (!cell.IsEmpty()) { - return false; - } - } - } + return true; +} - return true; +bool MainFrame::ClosePage(EditorPage* page) { + if (!page || !m_notebook) { + return false; } - wxString GetDisplayFileName() const { - if (!m_currentFile.IsEmpty()) { - return wxFileName(m_currentFile).GetFullName(); - } - return m_documentName; + if (!page->ConfirmClose()) { + return false; } - int GetActiveRowIndex() const { - if (m_contextRow >= 0) { - return m_contextRow; - } - return m_grid ? m_grid->GetGridCursorRow() : -1; + if (m_notebook->GetPageCount() <= 1) { + Close(); + return true; } - int GetActiveColumnIndex() const { - if (m_contextColumn >= 0) { - return m_contextColumn; - } - return m_grid ? m_grid->GetGridCursorCol() : -1; + const int index = m_notebook->FindPage(page); + if (index == wxNOT_FOUND) { + return false; } - void GoToRow(int row) { - if (!m_grid || m_rows.empty()) { - return; - } - - row = std::clamp(row, 0, static_cast(m_rows.size()) - 1); - int col = m_grid->GetGridCursorCol(); - if (col < 0) { - col = 0; - } - if (m_grid->GetNumberCols() <= 0) { - return; - } - col = std::clamp(col, 0, m_grid->GetNumberCols() - 1); - SelectCell(static_cast(row), static_cast(col)); + m_notebook->DeletePage(static_cast(index)); + UpdateFrameTitle(); + UpdateStatusBar(); + if (EditorPage* activePage = GetActivePage()) { + activePage->FocusEditor(); } + return true; +} - wxRect GetHeaderRect(int col) const { - if (!m_grid || col < 0 || col >= m_grid->GetNumberCols()) { - return {}; - } +void MainFrame::OnNewWindow(wxCommandEvent&) { + CreateAndShowMainFrame({}); +} - int x = 0; - for (int i = 0; i < col; ++i) { - x += m_grid->GetColSize(i); - } +void MainFrame::OnNewTab(wxCommandEvent&) { + CreateBlankTab(true, true); +} - const int scrollOffset = m_grid->GetScrollPos(wxHORIZONTAL) * m_grid->GetScrollLineX(); - x -= scrollOffset; +void MainFrame::OnCloseWindow(wxCommandEvent&) { + Close(); +} - return wxRect(x, 0, m_grid->GetColSize(col), m_grid->GetColLabelSize()); - } +void MainFrame::OnCloseTab(wxCommandEvent&) { + ClosePage(GetActivePage()); +} - void RepositionHeaderEditor() { - if (!m_headerEditor || m_activeHeaderColumn < 0) { - return; - } +void MainFrame::OnOpen(wxCommandEvent&) { + wxFileDialog dialog( + this, + "Open CSV file", + {}, + {}, + "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + wxFD_OPEN | wxFD_FILE_MUST_EXIST); + if (dialog.ShowModal() == wxID_OK) { + OpenDocumentPath(dialog.GetPath()); + } +} - const wxRect rect = GetHeaderRect(m_activeHeaderColumn); - if (rect.width <= 0 || rect.height <= 0) { - m_headerEditor->Hide(); - return; - } +void MainFrame::OnSave(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->SaveCurrentFile(); + } +} - const int inset = FromDIP(1); - m_headerEditor->SetSize( - rect.x + inset, - rect.y + inset, - std::max(10, rect.width - inset * 2), - std::max(10, rect.height - inset * 2)); - m_headerEditor->Show(); - m_headerEditor->Raise(); +void MainFrame::OnSaveAs(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->SaveCurrentFileAs(); } +} - void BeginHeaderEdit(int col) { - if (!m_headerEditor || col < 0 || col >= static_cast(GetColumnCount())) { - return; - } +void MainFrame::OnPrintPreview(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->ShowPrintPreview(); + } +} - CommitActiveEdit(); - m_activeHeaderColumn = col; - m_headerEditor->ChangeValue(m_headers[col]); - RepositionHeaderEditor(); - m_headerEditor->SetFocus(); - m_headerEditor->SelectAll(); +void MainFrame::OnPrint(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->ShowPrint(); } +} - void FocusFirstDataCellForEntry() { - if (!m_grid || m_grid->GetNumberCols() <= 0 || m_grid->GetNumberRows() <= 0) { - return; - } +void MainFrame::OnExit(wxCommandEvent&) { + Close(); +} - SelectCell(0, 0); - m_grid->EnableCellEditControl(); +void MainFrame::OnCopy(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->CopySelection(); } +} - void CommitHeaderEdit() { - if (!m_headerEditor || m_activeHeaderColumn < 0) { - return; - } +void MainFrame::OnGoToFirst(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->GoToFirst(); + } +} - const int col = m_activeHeaderColumn; - const wxString updatedValue = m_headerEditor->GetValue(); - m_headerEditor->Hide(); - m_activeHeaderColumn = -1; +void MainFrame::OnGoToLast(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->GoToLast(); + } +} - if (col >= static_cast(m_headers.size()) || m_headers[col] == updatedValue) { - return; - } +void MainFrame::OnGoToRow(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->PromptGoToRow(); + } +} - m_headers[col] = updatedValue; - m_grid->SetColLabelValue(col, updatedValue.IsEmpty() ? wxString::Format("Column %d", col + 1) : updatedValue); - SetDirty(true); +void MainFrame::OnFind(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->ShowFindDialog(); } +} - void CancelHeaderEdit() { - if (!m_headerEditor) { - return; - } +void MainFrame::OnFindNext(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->FindNext(); + } +} - m_headerEditor->Hide(); - m_activeHeaderColumn = -1; +void MainFrame::OnFindPrevious(wxCommandEvent&) { + if (EditorPage* page = GetActivePage()) { + page->FindPrevious(); } +} - void SyncCellFromGrid(int row, int col) { - if (row < 0 || col < 0 || row >= static_cast(m_rows.size())) { - return; - } +void MainFrame::OnAbout(wxCommandEvent&) { + ShowAboutDialog(this); +} - const wxString updatedValue = m_grid->GetCellValue(row, col); - if (GetCellText(static_cast(row), static_cast(col)) == updatedValue) { - return; - } +void MainFrame::OnNotebookPageChanged(wxAuiNotebookEvent& event) { + UpdateFrameTitle(); + UpdateStatusBar(); + if (EditorPage* page = GetActivePage()) { + page->FocusEditor(); + } + event.Skip(); +} - SetCellText(static_cast(row), static_cast(col), updatedValue); - SetDirty(true); +void MainFrame::OnNotebookPageClose(wxAuiNotebookEvent& event) { + const int selection = event.GetSelection(); + if (!m_notebook || selection == wxNOT_FOUND) { + event.Veto(); + return; } - bool SaveToPath(const wxString& path) { - CommitHeaderEdit(); - CommitActiveEdit(); + auto* page = dynamic_cast(m_notebook->GetPage(static_cast(selection))); + if (!page) { + event.Veto(); + return; + } - wxFFile file(path, "w"); - if (!file.IsOpened()) { - wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV"); - return false; - } + if (!page->ConfirmClose()) { + event.Veto(); + return; + } - std::vector headers = m_headers; - headers.resize(GetColumnCount()); - if (!file.Write(BuildCsvLine(headers) + "\n")) { - wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV"); - return false; - } + if (m_notebook->GetPageCount() <= 1) { + event.Veto(); + Close(); + } +} - for (const auto& row : m_rows) { - std::vector normalizedRow = row; - normalizedRow.resize(GetColumnCount()); - if (!file.Write(BuildCsvLine(normalizedRow) + "\n")) { - wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV"); - return false; - } - } +void MainFrame::OnActivate(wxActivateEvent& event) { + if (event.GetActive() && wxTheApp) { + wxTheApp->SetTopWindow(this); + } + event.Skip(); +} - m_currentFile = path; - m_documentName = wxFileName(path).GetFullName(); - SetDirty(false); - UpdateTitle(); - return true; +void MainFrame::OnClose(wxCloseEvent& event) { + if (!ConfirmCloseAllPages()) { + event.Veto(); + return; } - bool SaveCurrentFile() { - if (m_currentFile.IsEmpty()) { - return SaveCurrentFileAs(); - } - return SaveToPath(m_currentFile); - } - - bool SaveCurrentFileAs() { - wxFileDialog dialog( - this, - "Save CSV file", - {}, - m_currentFile.IsEmpty() ? m_documentName : wxFileName(m_currentFile).GetFullName(), - "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - wxFD_SAVE | wxFD_OVERWRITE_PROMPT); - if (dialog.ShowModal() != wxID_OK) { - return false; - } - return SaveToPath(dialog.GetPath()); + MainFrame* otherFrame = FindOtherMainFrame(this); + if (wxTheApp && wxTheApp->GetTopWindow() == this) { + wxTheApp->SetTopWindow(otherFrame); } - bool ConfirmDirtyFileAction() { - if (!m_isDirty) { - return true; - } + event.Skip(); - if (IsEffectivelyEmptyDocument()) { - return true; - } + if (!otherFrame && wxTheApp) { + wxTheApp->CallAfter([] + { + if (wxTheApp) { + wxTheApp->ExitMainLoop(); + } + }); + } +} - switch (ShowDirtyFileDialog(this, GetDisplayFileName())) { - case DirtyFileAction::Save: - return SaveCurrentFile(); - case DirtyFileAction::SaveAs: - return SaveCurrentFileAs(); - case DirtyFileAction::Discard: - return true; - case DirtyFileAction::Cancel: - return false; - } +EditorPage::EditorPage(MainFrame& owner, wxWindow* parent) + : wxPanel(parent), + m_owner(owner) { + SetDropTarget(new CsvFileDropTarget(*this)); + BuildGrid(); + + Bind(wxEVT_MENU, &EditorPage::OnContextCopyRow, this, ID_CONTEXT_COPY_ROW); + Bind(wxEVT_MENU, &EditorPage::OnContextCopyCell, this, ID_CONTEXT_COPY_CELL); + Bind(wxEVT_MENU, &EditorPage::OnInsertRowBefore, this, ID_INSERT_ROW_BEFORE); + Bind(wxEVT_MENU, &EditorPage::OnInsertRowAfter, this, ID_INSERT_ROW_AFTER); + Bind(wxEVT_MENU, &EditorPage::OnInsertColumnBefore, this, ID_INSERT_COLUMN_BEFORE); + Bind(wxEVT_MENU, &EditorPage::OnInsertColumnAfter, this, ID_INSERT_COLUMN_AFTER); + Bind(wxEVT_FIND, &EditorPage::OnFindDialog, this); + Bind(wxEVT_FIND_NEXT, &EditorPage::OnFindDialog, this); + Bind(wxEVT_FIND_CLOSE, &EditorPage::OnFindDialogClose, this); +} - return false; +EditorPage::~EditorPage() { + if (m_findDialog) { + m_findDialog->Destroy(); + m_findDialog = nullptr; } +} - PrintableDocument BuildPrintableDocument() const { - PrintableDocument document; - document.title = GetDisplayFileName(); - document.headers = m_headers; - document.rows = m_rows; - return document; - } +void EditorPage::BuildGrid() { + m_grid = new wxGrid(this, wxID_ANY); + m_grid->CreateGrid(0, 0); + m_grid->EnableEditing(true); + m_grid->EnableDragRowSize(false); + m_grid->EnableDragColSize(true); + m_grid->SetDefaultCellOverflow(false); + m_grid->SetRowLabelSize(0); + m_grid->SetColLabelAlignment(wxALIGN_LEFT, wxALIGN_CENTER); + m_grid->SetDropTarget(new CsvFileDropTarget(*this)); + m_grid->GetGridWindow()->SetDropTarget(new CsvFileDropTarget(*this)); + + m_grid->Bind(wxEVT_GRID_CELL_LEFT_DCLICK, &EditorPage::OnCellLeftDClick, this); + m_grid->Bind(wxEVT_GRID_CELL_RIGHT_CLICK, &EditorPage::OnCellRightClick, this); + m_grid->Bind(wxEVT_GRID_LABEL_LEFT_DCLICK, &EditorPage::OnLabelLeftDClick, this); + m_grid->Bind(wxEVT_GRID_LABEL_RIGHT_CLICK, &EditorPage::OnLabelRightClick, this); + m_grid->Bind(wxEVT_GRID_SELECT_CELL, &EditorPage::OnSelectCell, this); + m_grid->Bind(wxEVT_GRID_CELL_CHANGED, &EditorPage::OnCellChanged, this); + m_grid->Bind(wxEVT_GRID_EDITOR_SHOWN, &EditorPage::OnEditorShown, this); + m_grid->Bind(wxEVT_CHAR_HOOK, &EditorPage::OnGridCharHook, this); + m_grid->Bind(wxEVT_SIZE, &EditorPage::OnGridResized, this); + m_grid->GetGridWindow()->Bind(wxEVT_LEFT_DCLICK, &EditorPage::OnGridWindowLeftDClick, this); + + m_headerEditor = new wxTextCtrl( + m_grid->GetGridColLabelWindow(), + wxID_ANY, + {}, + wxDefaultPosition, + wxDefaultSize, + wxTE_PROCESS_ENTER); + m_headerEditor->Hide(); + m_headerEditor->Bind(wxEVT_TEXT_ENTER, &EditorPage::OnHeaderEditorEnter, this); + m_headerEditor->Bind(wxEVT_KILL_FOCUS, &EditorPage::OnHeaderEditorKillFocus, this); + m_headerEditor->Bind(wxEVT_CHAR_HOOK, &EditorPage::OnHeaderEditorCharHook, this); + + auto* sizer = new wxBoxSizer(wxVERTICAL); + sizer->Add(m_grid, 1, wxEXPAND | wxALL, 0); + SetSizer(sizer); +} - void CreateNewFile() { - CancelHeaderEdit(); - CommitActiveEdit(); +void EditorPage::NotifyStateChanged() { + m_owner.NotifyPageStateChanged(this); +} - m_rows.assign(1, std::vector(1, wxString())); - m_headers.assign(1, wxString()); - m_currentFile.clear(); - m_documentName = "untitled.csv"; - m_lastFindValid = false; - m_lastFindIndex = 0; - - RefreshGridFromData(); - ResizeToCsvContent(); - SetDirty(true); - UpdateTitle(); - UpdateStatusBar(); +void EditorPage::SetDirty(bool dirty) { + if (m_isDirty == dirty) { + return; + } - if (m_grid->GetNumberCols() > 0) { - BeginHeaderEdit(0); - } + m_isDirty = dirty; + NotifyStateChanged(); +} + +unsigned int EditorPage::GetColumnCount() const { + unsigned int columnCount = static_cast(m_headers.size()); + for (const auto& row : m_rows) { + columnCount = std::max(columnCount, static_cast(row.size())); } + return columnCount; +} - void OpenFile(const wxString& path) { - CancelHeaderEdit(); - std::vector> rows; - std::vector headers; - if (!wxFileExists(path)) { - wxMessageBox(wxString::Format("File not found:\n%s", path), "Open CSV"); - return; - } - if (!ParseCsvFile(path, headers, rows)) { - wxMessageBox(wxString::Format("Unable to read file:\n%s", path), "Open CSV"); - return; - } +void EditorPage::NormalizeRows(unsigned int minimumColumnCount) { + const unsigned int columnCount = std::max(GetColumnCount(), minimumColumnCount); + m_headers.resize(columnCount); + for (auto& row : m_rows) { + row.resize(columnCount); + } +} - m_rows = std::move(rows); - m_headers = std::move(headers); - NormalizeRows(static_cast(m_headers.size())); - RefreshGridFromData(); - m_grid->ClearSelection(); - - m_currentFile = path; - m_documentName = wxFileName(path).GetFullName(); - m_lastFindValid = false; - m_lastFindIndex = 0; - SetDirty(false); - UpdateTitle(); - UpdateStatusBar(); - ResizeToCsvContent(); +void EditorPage::EnsureRowCount(int desiredRows) { + const int currentRows = m_grid->GetNumberRows(); + if (currentRows < desiredRows) { + m_grid->AppendRows(desiredRows - currentRows); + } else if (currentRows > desiredRows) { + m_grid->DeleteRows(0, currentRows - desiredRows); } +} - bool HandleDroppedFiles(const wxArrayString& filenames) { - if (!IsEffectivelyEmptyDocument() || filenames.empty()) { - return false; - } +void EditorPage::EnsureColumnCount(int desiredColumns) { + const int currentColumns = m_grid->GetNumberCols(); + if (currentColumns < desiredColumns) { + m_grid->AppendCols(desiredColumns - currentColumns); + } else if (currentColumns > desiredColumns) { + m_grid->DeleteCols(desiredColumns, currentColumns - desiredColumns); + } +} - return OpenDocumentFile(filenames[0]); +wxString EditorPage::GetCellText(unsigned int row, unsigned int col) const { + if (row < m_rows.size() && col < m_rows[row].size()) { + return m_rows[row][col]; } + return {}; +} - void ResizeToCsvContent() { - if (!m_grid || m_rows.empty() || GetColumnCount() == 0) { - return; - } +void EditorPage::SetCellText(unsigned int row, unsigned int col, const wxString& value) { + if (row >= m_rows.size()) { + return; + } - wxSize displaySize = wxGetDisplaySize(); - const int maxWidth = displaySize.GetWidth() * 66 / 100; - const int maxHeight = displaySize.GetHeight() * 66 / 100; - - wxClientDC dc(this); - dc.SetFont(m_grid->GetFont()); - const int charHeight = dc.GetTextExtent("M").GetHeight(); - const int rowHeight = std::max(22, charHeight + 8); - const unsigned int columns = GetColumnCount(); - const unsigned int rows = static_cast(m_rows.size()); - const unsigned int sampleRows = std::min(rows, 120u); - - int totalWidth = 0; - for (unsigned int col = 0; col < columns; ++col) { - wxString header = wxString::Format("Column %u", col + 1); - if (col < m_headers.size() && !m_headers[col].IsEmpty()) { - header = m_headers[col]; - } + if (col >= m_rows[row].size()) { + m_rows[row].resize(col + 1); + } - int colWidth = dc.GetTextExtent(header).GetWidth() + 32; - for (unsigned int row = 0; row < sampleRows; ++row) { - colWidth = std::max(colWidth, dc.GetTextExtent(GetCellText(row, col)).GetWidth() + 32); - } - colWidth = std::clamp(colWidth, 100, 400); - totalWidth += colWidth; - if (col < static_cast(m_grid->GetNumberCols())) { - m_grid->SetColSize(static_cast(col), colWidth); - } - } + m_rows[row][col] = value; + if (row < static_cast(m_grid->GetNumberRows()) && col < static_cast(m_grid->GetNumberCols())) { + m_grid->SetCellValue(static_cast(row), static_cast(col), value); + } +} - const int frameExtraX = GetSize().GetWidth() - GetClientSize().GetWidth(); - const int frameExtraY = GetSize().GetHeight() - GetClientSize().GetHeight(); - const int verticalScrollbar = 18; - const int horizontalScrollbar = 18; - const int headerHeight = rowHeight + 12; - const unsigned int visibleRows = std::min(rows, 24u); +void EditorPage::RefreshGridFromData() { + const int rows = static_cast(m_rows.size()); + const int columns = static_cast(GetColumnCount()); + m_isRefreshingGrid = true; - int desiredWidth = totalWidth + frameExtraX + verticalScrollbar + m_grid->GetRowLabelSize() + 4; - int desiredHeight = static_cast(visibleRows * rowHeight + headerHeight + frameExtraY + horizontalScrollbar + 24); + EnsureColumnCount(columns); + EnsureRowCount(rows); - int newWidth = std::max(640, std::min(desiredWidth, maxWidth)); - int newHeight = std::max(360, std::min(desiredHeight, maxHeight)); - SetSize(newWidth, newHeight); - Centre(); + for (int col = 0; col < columns; ++col) { + wxString title = wxString::Format("Column %d", col + 1); + if (col < static_cast(m_headers.size()) && !m_headers[col].IsEmpty()) { + title = m_headers[col]; + } + m_grid->SetColLabelValue(col, title); } - wxString GetBlockText(int topRow, int leftCol, int bottomRow, int rightCol) const { - wxString output; - for (int row = topRow; row <= bottomRow; ++row) { - if (!output.IsEmpty()) { - output << '\n'; - } - for (int col = leftCol; col <= rightCol; ++col) { - if (col > leftCol) { - output << '\t'; - } - output << GetCellText(static_cast(row), static_cast(col)); - } - } - return output; - } - - void CopySelection() { - const wxGridCellCoordsArray topLeft = m_grid->GetSelectionBlockTopLeft(); - const wxGridCellCoordsArray bottomRight = m_grid->GetSelectionBlockBottomRight(); - if (!topLeft.empty() && topLeft.size() == bottomRight.size()) { - CopyToClipboard(GetBlockText( - topLeft[0].GetRow(), - topLeft[0].GetCol(), - bottomRight[0].GetRow(), - bottomRight[0].GetCol())); - return; + for (int row = 0; row < rows; ++row) { + for (int col = 0; col < columns; ++col) { + m_grid->SetCellValue(row, col, GetCellText(static_cast(row), static_cast(col))); } + } - wxArrayInt selectedRows = m_grid->GetSelectedRows(); - if (!selectedRows.empty()) { - wxString output; - for (size_t i = 0; i < selectedRows.size(); ++i) { - if (!output.IsEmpty()) { - output << '\n'; - } - output << GetBlockText(selectedRows[i], 0, selectedRows[i], m_grid->GetNumberCols() - 1); - } - CopyToClipboard(output); - return; - } + m_isRefreshingGrid = false; + RepositionHeaderEditor(); + NotifyStateChanged(); +} - wxGridCellCoordsArray selectedCells = m_grid->GetSelectedCells(); - if (!selectedCells.empty()) { - const wxGridCellCoords& cell = selectedCells[0]; - CopyToClipboard(GetCellText(static_cast(cell.GetRow()), static_cast(cell.GetCol()))); - return; - } +bool EditorPage::IsEffectivelyEmptyDocument() const { + if (!m_currentFile.IsEmpty()) { + return false; + } - const int row = m_grid->GetGridCursorRow(); - const int col = m_grid->GetGridCursorCol(); - if (row >= 0 && col >= 0) { - CopyToClipboard(GetCellText(static_cast(row), static_cast(col))); - } + if (GetColumnCount() != 1 || m_headers.size() != 1) { + return false; } - void CopyRow(int row) { - if (row < 0 || row >= static_cast(m_rows.size()) || m_grid->GetNumberCols() == 0) { - return; - } - CopyToClipboard(GetBlockText(row, 0, row, m_grid->GetNumberCols() - 1)); + const wxString& header = m_headers[0]; + if (!header.IsEmpty() && header != "New column" && header != "Column 1") { + return false; } - void CopyCell(int row, int column) { - if (row < 0 || column < 0) { - return; + for (const auto& row : m_rows) { + for (const auto& cell : row) { + if (!cell.IsEmpty()) { + return false; + } } - CopyToClipboard(GetCellText(static_cast(row), static_cast(column))); } - void CopyToClipboard(const wxString& output) { - if (output.IsEmpty()) { - return; - } - auto* text = new wxTextDataObject(output); - if (wxTheClipboard->Open()) { - wxTheClipboard->SetData(text); - wxTheClipboard->Close(); - } + return true; +} + +wxString EditorPage::GetDisplayFileName() const { + if (!m_currentFile.IsEmpty()) { + return wxFileName(m_currentFile).GetFullName(); } + return m_documentName; +} - void CommitActiveEdit() { - if (!m_grid->IsCellEditControlShown()) { - return; - } +wxString EditorPage::GetTabLabel() const { + wxString label = GetDisplayFileName(); + if (m_isDirty) { + label += " *"; + } + return label; +} + +PrintableDocument EditorPage::BuildPrintableDocument() const { + PrintableDocument document; + document.title = GetDisplayFileName(); + document.headers = m_headers; + document.rows = m_rows; + return document; +} - const int row = m_grid->GetGridCursorRow(); - const int col = m_grid->GetGridCursorCol(); - m_grid->SaveEditControlValue(); - const wxString value = m_grid->GetCellValue(row, col); - m_grid->HideCellEditControl(); - m_grid->DisableCellEditControl(); - SetCellText(static_cast(row), static_cast(col), value); +wxString EditorPage::GetStatusPrimaryText() const { + return m_isDirty ? "Modified" : wxString(); +} + +wxString EditorPage::GetStatusSecondaryText() const { + const int totalRows = static_cast(m_rows.size()); + const int selectedRow = m_grid ? m_grid->GetGridCursorRow() : -1; + return wxString::Format( + "Row %d of %d", + totalRows > 0 && selectedRow >= 0 ? selectedRow + 1 : 0, + totalRows); +} + +int EditorPage::GetActiveRowIndex() const { + if (m_contextRow >= 0) { + return m_contextRow; } + return m_grid ? m_grid->GetGridCursorRow() : -1; +} - void AppendEmptyRow() { - m_rows.emplace_back(GetColumnCount()); - RefreshGridFromData(); - UpdateStatusBar(); +int EditorPage::GetActiveColumnIndex() const { + if (m_contextColumn >= 0) { + return m_contextColumn; } + return m_grid ? m_grid->GetGridCursorCol() : -1; +} - void InsertColumn(int insertAt) { - CommitHeaderEdit(); - CommitActiveEdit(); +void EditorPage::GoToRow(int row) { + if (!m_grid || m_rows.empty()) { + return; + } - insertAt = std::clamp(insertAt, 0, static_cast(GetColumnCount())); - m_headers.insert(m_headers.begin() + insertAt, "New column"); - for (auto& row : m_rows) { - row.insert(row.begin() + insertAt, wxString()); - } + row = std::clamp(row, 0, static_cast(m_rows.size()) - 1); + int col = m_grid->GetGridCursorCol(); + if (col < 0) { + col = 0; + } + if (m_grid->GetNumberCols() <= 0) { + return; + } + col = std::clamp(col, 0, m_grid->GetNumberCols() - 1); + SelectCell(static_cast(row), static_cast(col)); +} - NormalizeRows(static_cast(m_headers.size())); - RefreshGridFromData(); - ResizeToCsvContent(); - SetDirty(true); - BeginHeaderEdit(insertAt); +wxRect EditorPage::GetHeaderRect(int col) const { + if (!m_grid || col < 0 || col >= m_grid->GetNumberCols()) { + return {}; } - void InsertRow(int insertAt) { - CommitHeaderEdit(); - CommitActiveEdit(); + int x = 0; + for (int i = 0; i < col; ++i) { + x += m_grid->GetColSize(i); + } - insertAt = std::clamp(insertAt, 0, static_cast(m_rows.size())); - m_rows.insert(m_rows.begin() + insertAt, std::vector(GetColumnCount(), wxString())); + const int scrollOffset = m_grid->GetScrollPos(wxHORIZONTAL) * m_grid->GetScrollLineX(); + x -= scrollOffset; - RefreshGridFromData(); - UpdateStatusBar(); - SetDirty(true); + return wxRect(x, 0, m_grid->GetColSize(col), m_grid->GetColLabelSize()); +} - if (GetColumnCount() > 0) { - SelectCell(static_cast(insertAt), 0); - m_grid->EnableCellEditControl(); - } +void EditorPage::RepositionHeaderEditor() { + if (!m_headerEditor || m_activeHeaderColumn < 0) { + return; } - void MoveToNextEditableCell() { - if (m_grid->GetNumberCols() == 0) { - return; - } + const wxRect rect = GetHeaderRect(m_activeHeaderColumn); + if (rect.width <= 0 || rect.height <= 0) { + m_headerEditor->Hide(); + return; + } + + const int inset = FromDIP(1); + m_headerEditor->SetSize( + rect.x + inset, + rect.y + inset, + std::max(10, rect.width - inset * 2), + std::max(10, rect.height - inset * 2)); + m_headerEditor->Show(); + m_headerEditor->Raise(); +} - const int currentRow = m_grid->GetGridCursorRow(); - const int currentCol = m_grid->GetGridCursorCol(); - int nextRow = currentRow; - int nextCol = currentCol + 1; +void EditorPage::BeginHeaderEdit(int col) { + if (!m_headerEditor || col < 0 || col >= static_cast(GetColumnCount())) { + return; + } - if (nextCol >= m_grid->GetNumberCols()) { - nextCol = 0; - ++nextRow; - } + CommitActiveEdit(); + m_activeHeaderColumn = col; + m_headerEditor->ChangeValue(m_headers[col]); + RepositionHeaderEditor(); + m_headerEditor->SetFocus(); + m_headerEditor->SelectAll(); +} - if (nextRow >= m_grid->GetNumberRows()) { - AppendEmptyRow(); - } +void EditorPage::FocusFirstDataCellForEntry() { + if (!m_grid || m_grid->GetNumberCols() <= 0 || m_grid->GetNumberRows() <= 0) { + return; + } - SelectCell(static_cast(nextRow), static_cast(nextCol)); - m_grid->EnableCellEditControl(); + SelectCell(0, 0); + m_grid->EnableCellEditControl(); +} + +void EditorPage::CommitHeaderEdit() { + if (!m_headerEditor || m_activeHeaderColumn < 0) { + return; } - void OnContextCopyRow(wxCommandEvent&) { - CopyRow(GetActiveRowIndex()); + const int col = m_activeHeaderColumn; + const wxString updatedValue = m_headerEditor->GetValue(); + m_headerEditor->Hide(); + m_activeHeaderColumn = -1; + + if (col >= static_cast(m_headers.size()) || m_headers[col] == updatedValue) { + return; } - void OnContextCopyCell(wxCommandEvent&) { - const int row = GetActiveRowIndex(); - const int col = GetActiveColumnIndex(); - CopyCell(row, col); + m_headers[col] = updatedValue; + m_grid->SetColLabelValue(col, updatedValue.IsEmpty() ? wxString::Format("Column %d", col + 1) : updatedValue); + SetDirty(true); +} + +void EditorPage::CancelHeaderEdit() { + if (!m_headerEditor) { + return; } - void OnInsertColumnBefore(wxCommandEvent&) { - const int col = GetActiveColumnIndex(); - if (col >= 0) { - InsertColumn(col); - } + m_headerEditor->Hide(); + m_activeHeaderColumn = -1; +} + +void EditorPage::SyncCellFromGrid(int row, int col) { + if (row < 0 || col < 0 || row >= static_cast(m_rows.size())) { + return; } - void OnInsertColumnAfter(wxCommandEvent&) { - const int col = GetActiveColumnIndex(); - if (col >= 0) { - InsertColumn(col + 1); - } + const wxString updatedValue = m_grid->GetCellValue(row, col); + if (GetCellText(static_cast(row), static_cast(col)) == updatedValue) { + return; } - void OnInsertRowBefore(wxCommandEvent&) { - const int row = GetActiveRowIndex(); - if (row >= 0) { - InsertRow(row); - } + SetCellText(static_cast(row), static_cast(col), updatedValue); + SetDirty(true); +} + +bool EditorPage::SaveToPath(const wxString& path) { + CommitHeaderEdit(); + CommitActiveEdit(); + + wxFFile file(path, "w"); + if (!file.IsOpened()) { + wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV", wxOK | wxICON_ERROR, this); + return false; } - void OnInsertRowAfter(wxCommandEvent&) { - const int row = GetActiveRowIndex(); - if (row >= 0) { - InsertRow(row + 1); - } + std::vector headers = m_headers; + headers.resize(GetColumnCount()); + if (!file.Write(BuildCsvLine(headers) + "\n")) { + wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV", wxOK | wxICON_ERROR, this); + return false; } - void ShowContextMenu(const wxPoint& position) { - wxMenu menu; - menu.Append(ID_CONTEXT_COPY_ROW, "&Copy row"); - menu.Append(ID_CONTEXT_COPY_CELL, "Copy &cell"); - menu.AppendSeparator(); - menu.Append(ID_INSERT_ROW_BEFORE, "Insert row &before"); - menu.Append(ID_INSERT_ROW_AFTER, "Insert row &after"); - const bool hasTarget = m_contextRow >= 0; - menu.Enable(ID_CONTEXT_COPY_ROW, hasTarget); - menu.Enable(ID_CONTEXT_COPY_CELL, hasTarget && m_contextColumn >= 0); - menu.Enable(ID_INSERT_ROW_BEFORE, hasTarget); - menu.Enable(ID_INSERT_ROW_AFTER, hasTarget); - m_grid->PopupMenu(&menu, position); - } - - void ShowHeaderContextMenu(const wxPoint& position) { - wxMenu menu; - menu.Append(ID_INSERT_COLUMN_BEFORE, "Insert column &before"); - menu.Append(ID_INSERT_COLUMN_AFTER, "Insert column &after"); - const bool hasTarget = m_contextColumn >= 0; - menu.Enable(ID_INSERT_COLUMN_BEFORE, hasTarget); - menu.Enable(ID_INSERT_COLUMN_AFTER, hasTarget); - m_grid->GetGridColLabelWindow()->PopupMenu(&menu, position); - } - - bool FindInData(bool forward) { - const wxString query = m_findData.GetFindString(); - const bool matchCase = (m_findData.GetFlags() & wxFR_MATCHCASE) != 0; - const unsigned int rows = static_cast(m_rows.size()); - const unsigned int cols = GetColumnCount(); - const size_t total = static_cast(rows) * cols; - if (query.IsEmpty() || total == 0) { + for (const auto& row : m_rows) { + std::vector normalizedRow = row; + normalizedRow.resize(GetColumnCount()); + if (!file.Write(BuildCsvLine(normalizedRow) + "\n")) { + wxMessageBox(wxString::Format("Unable to write file:\n%s", path), "Save CSV", wxOK | wxICON_ERROR, this); return false; } + } - for (size_t step = 0; step < total; ++step) { - size_t linearIndex = 0; - if (m_lastFindValid) { - const size_t lastIndex = static_cast(m_lastFindIndex); - if (forward) { - linearIndex = (lastIndex + 1 + step) % total; - } else { - linearIndex = (lastIndex + total - 1 - step) % total; - } - } else if (forward) { - linearIndex = step; - } else { - linearIndex = total - 1 - step; - } + m_currentFile = path; + m_documentName = wxFileName(path).GetFullName(); + SetDirty(false); + NotifyStateChanged(); + return true; +} - const unsigned int row = static_cast(linearIndex / cols); - const unsigned int col = static_cast(linearIndex % cols); - const wxString value = GetCellText(row, col); - if (ContainsText(value, query, matchCase)) { - SelectCell(row, col); - m_lastFindValid = true; - m_lastFindIndex = linearIndex; - return true; - } - } - return false; +bool EditorPage::SaveCurrentFile() { + if (m_currentFile.IsEmpty()) { + return SaveCurrentFileAs(); } + return SaveToPath(m_currentFile); +} - void SelectCell(unsigned int row, unsigned int col) { - m_grid->ClearSelection(); - m_grid->SetGridCursor(static_cast(row), static_cast(col)); - m_grid->SelectBlock(static_cast(row), static_cast(col), static_cast(row), static_cast(col), false); - m_grid->MakeCellVisible(static_cast(row), static_cast(col)); - m_grid->SetFocus(); - UpdateStatusBar(); +bool EditorPage::SaveCurrentFileAs() { + wxFileDialog dialog( + this, + "Save CSV file", + {}, + m_currentFile.IsEmpty() ? m_documentName : wxFileName(m_currentFile).GetFullName(), + "CSV files (*.csv)|*.csv|All files (*.*)|*.*", + wxFD_SAVE | wxFD_OVERWRITE_PROMPT); + if (dialog.ShowModal() != wxID_OK) { + return false; } + return SaveToPath(dialog.GetPath()); +} - void OnOpen(wxCommandEvent&) { - if (!ConfirmDirtyFileAction()) { - return; - } +bool EditorPage::ConfirmClose() { + CommitHeaderEdit(); + if (!m_isDirty || IsEffectivelyEmptyDocument()) { + return true; + } - wxFileDialog dialog( - this, - "Open CSV file", - {}, - {}, - "CSV files (*.csv)|*.csv|All files (*.*)|*.*", - wxFD_OPEN | wxFD_FILE_MUST_EXIST); - if (dialog.ShowModal() == wxID_OK) { - OpenFile(dialog.GetPath()); - } + switch (ShowDirtyFileDialog(this, GetDisplayFileName())) { + case DirtyFileAction::Save: + return SaveCurrentFile(); + case DirtyFileAction::SaveAs: + return SaveCurrentFileAs(); + case DirtyFileAction::Discard: + return true; + case DirtyFileAction::Cancel: + return false; } - void OnNew(wxCommandEvent&) { - if (!ConfirmDirtyFileAction()) { - return; - } + return false; +} + +void EditorPage::CreateBlankDocument(bool startEditingHeader) { + CancelHeaderEdit(); + CommitActiveEdit(); + + m_rows.assign(1, std::vector(1, wxString())); + m_headers.assign(1, wxString()); + m_currentFile.clear(); + m_documentName = "untitled.csv"; + m_lastFindValid = false; + m_lastFindIndex = 0; + m_contextRow = -1; + m_contextColumn = -1; - CreateNewFile(); + RefreshGridFromData(); + m_isDirty = false; + NotifyStateChanged(); + + if (startEditingHeader && m_grid->GetNumberCols() > 0) { + BeginHeaderEdit(0); } +} - void OnExit(wxCommandEvent&) { - Close(); +void EditorPage::OpenFileInternal(const wxString& path) { + CancelHeaderEdit(); + + std::vector> rows; + std::vector headers; + if (!wxFileExists(path)) { + wxMessageBox(wxString::Format("File not found:\n%s", path), "Open CSV", wxOK | wxICON_ERROR, this); + return; + } + if (!ParseCsvFile(path, headers, rows)) { + wxMessageBox(wxString::Format("Unable to read file:\n%s", path), "Open CSV", wxOK | wxICON_ERROR, this); + return; } - void OnSave(wxCommandEvent&) { - SaveCurrentFile(); + m_rows = std::move(rows); + m_headers = std::move(headers); + NormalizeRows(static_cast(m_headers.size())); + RefreshGridFromData(); + m_grid->ClearSelection(); + + m_currentFile = path; + m_documentName = wxFileName(path).GetFullName(); + m_lastFindValid = false; + m_lastFindIndex = 0; + m_isDirty = false; + NotifyStateChanged(); + m_owner.ResizeToContentOnce(this); +} + +bool EditorPage::OpenDocumentFile(const wxString& path) { + if (!ConfirmClose()) { + return false; } - void OnSaveAs(wxCommandEvent&) { - SaveCurrentFileAs(); + const wxString previousPath = m_currentFile; + OpenFileInternal(path); + return m_currentFile == path && previousPath != path ? true : m_currentFile == path; +} + +bool EditorPage::HandleDroppedFiles(const wxArrayString& filenames) { + return m_owner.OpenDroppedFiles(this, filenames); +} + +void EditorPage::ResizeOwnerToContent() { + if (!m_grid || m_rows.empty() || GetColumnCount() == 0) { + return; } - void OnPrintPreview(wxCommandEvent&) { - CommitHeaderEdit(); - CommitActiveEdit(); - if (m_printData.GetOrientation() != wxLANDSCAPE && m_printData.GetOrientation() != wxPORTRAIT) { - m_printData.SetOrientation(GuessLandscapeForPrint(BuildPrintableDocument()) ? wxLANDSCAPE : wxPORTRAIT); + wxSize displaySize = wxGetDisplaySize(); + const int maxWidth = displaySize.GetWidth() * 66 / 100; + const int maxHeight = displaySize.GetHeight() * 66 / 100; + + wxClientDC dc(this); + dc.SetFont(m_grid->GetFont()); + const int charHeight = dc.GetTextExtent("M").GetHeight(); + const int rowHeight = std::max(22, charHeight + 8); + const unsigned int columns = GetColumnCount(); + const unsigned int rows = static_cast(m_rows.size()); + const unsigned int sampleRows = std::min(rows, 120u); + + int totalWidth = 0; + for (unsigned int col = 0; col < columns; ++col) { + wxString header = wxString::Format("Column %u", col + 1); + if (col < m_headers.size() && !m_headers[col].IsEmpty()) { + header = m_headers[col]; } - ShowPrintPreviewWindow(this, BuildPrintableDocument(), m_printData); - } - void OnPrint(wxCommandEvent&) { - CommitHeaderEdit(); - CommitActiveEdit(); - if (m_printData.GetOrientation() != wxLANDSCAPE && m_printData.GetOrientation() != wxPORTRAIT) { - m_printData.SetOrientation(GuessLandscapeForPrint(BuildPrintableDocument()) ? wxLANDSCAPE : wxPORTRAIT); + int colWidth = dc.GetTextExtent(header).GetWidth() + 32; + for (unsigned int row = 0; row < sampleRows; ++row) { + colWidth = std::max(colWidth, dc.GetTextExtent(GetCellText(row, col)).GetWidth() + 32); + } + colWidth = std::clamp(colWidth, 100, 400); + totalWidth += colWidth; + if (col < static_cast(m_grid->GetNumberCols())) { + m_grid->SetColSize(static_cast(col), colWidth); } - ShowPrintDialogForDocument(this, BuildPrintableDocument(), m_printData); } - void OnCopy(wxCommandEvent&) { - CopySelection(); + const int frameExtraX = m_owner.GetSize().GetWidth() - m_owner.GetClientSize().GetWidth(); + const int frameExtraY = m_owner.GetSize().GetHeight() - m_owner.GetClientSize().GetHeight(); + const int verticalScrollbar = 18; + const int horizontalScrollbar = 18; + const int headerHeight = rowHeight + 12; + const unsigned int visibleRows = std::min(rows, 24u); + + const int desiredWidth = totalWidth + frameExtraX + verticalScrollbar + m_grid->GetRowLabelSize() + 4; + const int desiredHeight = static_cast(visibleRows * rowHeight + headerHeight + frameExtraY + horizontalScrollbar + 24); + + const int newWidth = std::max(640, std::min(desiredWidth, maxWidth)); + const int newHeight = std::max(360, std::min(desiredHeight, maxHeight)); + m_owner.SetSize(newWidth, newHeight); + m_owner.Centre(); +} + +wxString EditorPage::GetBlockText(int topRow, int leftCol, int bottomRow, int rightCol) const { + wxString output; + for (int row = topRow; row <= bottomRow; ++row) { + if (!output.IsEmpty()) { + output << '\n'; + } + for (int col = leftCol; col <= rightCol; ++col) { + if (col > leftCol) { + output << '\t'; + } + output << GetCellText(static_cast(row), static_cast(col)); + } } + return output; +} - void OnGoToFirst(wxCommandEvent&) { - GoToRow(0); +void EditorPage::CopyToClipboard(const wxString& output) { + if (output.IsEmpty()) { + return; } - void OnGoToLast(wxCommandEvent&) { - if (!m_rows.empty()) { - GoToRow(static_cast(m_rows.size()) - 1); - } + auto* text = new wxTextDataObject(output); + if (wxTheClipboard->Open()) { + wxTheClipboard->SetData(text); + wxTheClipboard->Close(); } +} - void OnGoToRow(wxCommandEvent&) { - if (m_rows.empty()) { - return; - } +void EditorPage::CopySelection() { + const wxGridCellCoordsArray topLeft = m_grid->GetSelectionBlockTopLeft(); + const wxGridCellCoordsArray bottomRight = m_grid->GetSelectionBlockBottomRight(); + if (!topLeft.empty() && topLeft.size() == bottomRight.size()) { + CopyToClipboard(GetBlockText( + topLeft[0].GetRow(), + topLeft[0].GetCol(), + bottomRight[0].GetRow(), + bottomRight[0].GetCol())); + return; + } - int selectedRow = -1; - const int currentRow = std::max(0, m_grid->GetGridCursorRow()); - if (ShowGoToRowDialog(this, static_cast(m_rows.size()), currentRow, &selectedRow)) { - GoToRow(selectedRow); + wxArrayInt selectedRows = m_grid->GetSelectedRows(); + if (!selectedRows.empty()) { + wxString output; + for (size_t i = 0; i < selectedRows.size(); ++i) { + if (!output.IsEmpty()) { + output << '\n'; + } + output << GetBlockText(selectedRows[i], 0, selectedRows[i], m_grid->GetNumberCols() - 1); } + CopyToClipboard(output); + return; } - void OnFind(wxCommandEvent&) { - if (!m_findDialog) { - m_findDialog = new wxFindReplaceDialog(this, &m_findData, "Find"); - } - m_findDialog->Show(); - m_findDialog->Raise(); + wxGridCellCoordsArray selectedCells = m_grid->GetSelectedCells(); + if (!selectedCells.empty()) { + const wxGridCellCoords& cell = selectedCells[0]; + CopyToClipboard(GetCellText(static_cast(cell.GetRow()), static_cast(cell.GetCol()))); + return; } - void OnFindNext(wxCommandEvent&) { - if (m_findData.GetFindString().IsEmpty()) { - wxCommandEvent evt; - OnFind(evt); - return; - } - if (!FindInData(true)) { - wxMessageBox("No matching data found.", "Find"); - } + const int row = m_grid->GetGridCursorRow(); + const int col = m_grid->GetGridCursorCol(); + if (row >= 0 && col >= 0) { + CopyToClipboard(GetCellText(static_cast(row), static_cast(col))); } +} - void OnFindPrevious(wxCommandEvent&) { - if (m_findData.GetFindString().IsEmpty()) { - wxCommandEvent evt; - OnFind(evt); - return; - } - if (!FindInData(false)) { - wxMessageBox("No matching data found.", "Find"); - } +void EditorPage::CopyRow(int row) { + if (row < 0 || row >= static_cast(m_rows.size()) || m_grid->GetNumberCols() == 0) { + return; } + CopyToClipboard(GetBlockText(row, 0, row, m_grid->GetNumberCols() - 1)); +} - void OnFindDialog(wxFindDialogEvent& event) { - m_findData.SetFindString(event.GetFindString()); - const bool forward = (event.GetFlags() & wxFR_DOWN) != 0; - if (!FindInData(forward)) { - wxMessageBox("No matching data found.", "Find"); - } +void EditorPage::CopyCell(int row, int column) { + if (row < 0 || column < 0) { + return; } + CopyToClipboard(GetCellText(static_cast(row), static_cast(column))); +} - void OnFindDialogClose(wxFindDialogEvent&) { - if (m_findDialog) { - m_findDialog->Destroy(); - m_findDialog = nullptr; - } +void EditorPage::CommitActiveEdit() { + if (!m_grid->IsCellEditControlShown()) { + return; } - void OnAbout(wxCommandEvent&) { - ShowAboutDialog(this); + const int row = m_grid->GetGridCursorRow(); + const int col = m_grid->GetGridCursorCol(); + m_grid->SaveEditControlValue(); + const wxString value = m_grid->GetCellValue(row, col); + m_grid->HideCellEditControl(); + m_grid->DisableCellEditControl(); + SetCellText(static_cast(row), static_cast(col), value); +} + +void EditorPage::AppendEmptyRow() { + m_rows.emplace_back(GetColumnCount()); + RefreshGridFromData(); +} + +void EditorPage::InsertColumn(int insertAt) { + CommitHeaderEdit(); + CommitActiveEdit(); + + insertAt = std::clamp(insertAt, 0, static_cast(GetColumnCount())); + m_headers.insert(m_headers.begin() + insertAt, "New column"); + for (auto& row : m_rows) { + row.insert(row.begin() + insertAt, wxString()); } - void OnCellLeftDClick(wxGridEvent& event) { - CommitHeaderEdit(); - SelectCell(static_cast(event.GetRow()), static_cast(event.GetCol())); + NormalizeRows(static_cast(m_headers.size())); + RefreshGridFromData(); + SetDirty(true); + BeginHeaderEdit(insertAt); +} + +void EditorPage::InsertRow(int insertAt) { + CommitHeaderEdit(); + CommitActiveEdit(); + + insertAt = std::clamp(insertAt, 0, static_cast(m_rows.size())); + m_rows.insert(m_rows.begin() + insertAt, std::vector(GetColumnCount(), wxString())); + + RefreshGridFromData(); + SetDirty(true); + + if (GetColumnCount() > 0) { + SelectCell(static_cast(insertAt), 0); m_grid->EnableCellEditControl(); } +} - void OnCellRightClick(wxGridEvent& event) { - CancelHeaderEdit(); - m_contextRow = event.GetRow(); - m_contextColumn = event.GetCol(); - m_grid->SetGridCursor(m_contextRow, m_contextColumn); - ShowContextMenu(event.GetPosition()); +void EditorPage::MoveToNextEditableCell() { + if (m_grid->GetNumberCols() == 0) { + return; } - void OnLabelLeftDClick(wxGridEvent& event) { - if (event.GetRow() == -1 && event.GetCol() >= 0) { - BeginHeaderEdit(event.GetCol()); - return; - } - event.Skip(); + const int currentRow = m_grid->GetGridCursorRow(); + const int currentCol = m_grid->GetGridCursorCol(); + int nextRow = currentRow; + int nextCol = currentCol + 1; + + if (nextCol >= m_grid->GetNumberCols()) { + nextCol = 0; + ++nextRow; } - void OnLabelRightClick(wxGridEvent& event) { - if (event.GetRow() == -1 && event.GetCol() >= 0) { - CommitActiveEdit(); - m_contextRow = -1; - m_contextColumn = event.GetCol(); - ShowHeaderContextMenu(event.GetPosition()); - return; - } - event.Skip(); + if (nextRow >= m_grid->GetNumberRows()) { + AppendEmptyRow(); } - void OnEditorShown(wxGridEvent& event) { - m_contextRow = event.GetRow(); - m_contextColumn = event.GetCol(); - event.Skip(); + SelectCell(static_cast(nextRow), static_cast(nextCol)); + m_grid->EnableCellEditControl(); +} + +void EditorPage::SelectCell(unsigned int row, unsigned int col) { + m_grid->ClearSelection(); + m_grid->SetGridCursor(static_cast(row), static_cast(col)); + m_grid->SelectBlock(static_cast(row), static_cast(col), static_cast(row), static_cast(col), false); + m_grid->MakeCellVisible(static_cast(row), static_cast(col)); + m_grid->SetFocus(); + NotifyStateChanged(); +} + +bool EditorPage::FindInData(bool forward) { + const wxString query = m_findData.GetFindString(); + const bool matchCase = (m_findData.GetFlags() & wxFR_MATCHCASE) != 0; + const unsigned int rows = static_cast(m_rows.size()); + const unsigned int cols = GetColumnCount(); + const size_t total = static_cast(rows) * cols; + if (query.IsEmpty() || total == 0) { + return false; } - void OnCellChanged(wxGridEvent& event) { - if (!m_isRefreshingGrid) { - SyncCellFromGrid(event.GetRow(), event.GetCol()); + for (size_t step = 0; step < total; ++step) { + size_t linearIndex = 0; + if (m_lastFindValid) { + const size_t lastIndex = m_lastFindIndex; + if (forward) { + linearIndex = (lastIndex + 1 + step) % total; + } else { + linearIndex = (lastIndex + total - 1 - step) % total; + } + } else if (forward) { + linearIndex = step; + } else { + linearIndex = total - 1 - step; + } + + const unsigned int row = static_cast(linearIndex / cols); + const unsigned int col = static_cast(linearIndex % cols); + const wxString value = GetCellText(row, col); + if (ContainsText(value, query, matchCase)) { + SelectCell(row, col); + m_lastFindValid = true; + m_lastFindIndex = linearIndex; + return true; } - event.Skip(); } - void OnSelectCell(wxGridEvent& event) { - CommitHeaderEdit(); - event.Skip(); - UpdateStatusBar(); + return false; +} + +void EditorPage::GoToFirst() { + GoToRow(0); +} + +void EditorPage::GoToLast() { + if (!m_rows.empty()) { + GoToRow(static_cast(m_rows.size()) - 1); } +} - void OnGridCharHook(wxKeyEvent& event) { - if (m_activeHeaderColumn >= 0) { - event.Skip(); - return; - } +void EditorPage::PromptGoToRow() { + if (m_rows.empty()) { + return; + } - if (!m_grid->IsCellEditControlShown()) { - event.Skip(); - return; - } + int selectedRow = -1; + const int currentRow = std::max(0, m_grid->GetGridCursorRow()); + if (ShowGoToRowDialog(this, static_cast(m_rows.size()), currentRow, &selectedRow)) { + GoToRow(selectedRow); + } +} - const int keyCode = event.GetKeyCode(); - if (keyCode == WXK_RETURN || keyCode == WXK_NUMPAD_ENTER) { - CommitActiveEdit(); - return; - } +void EditorPage::ShowFindDialog() { + if (!m_findDialog) { + m_findDialog = new wxFindReplaceDialog(this, &m_findData, "Find"); + } + m_findDialog->Show(); + m_findDialog->Raise(); +} - if (keyCode == WXK_TAB && !event.ShiftDown()) { - CommitActiveEdit(); - MoveToNextEditableCell(); - return; - } +void EditorPage::FindNext() { + if (m_findData.GetFindString().IsEmpty()) { + ShowFindDialog(); + return; + } + if (!FindInData(true)) { + wxMessageBox("No matching data found.", "Find", wxOK | wxICON_INFORMATION, this); + } +} - event.Skip(); +void EditorPage::FindPrevious() { + if (m_findData.GetFindString().IsEmpty()) { + ShowFindDialog(); + return; } + if (!FindInData(false)) { + wxMessageBox("No matching data found.", "Find", wxOK | wxICON_INFORMATION, this); + } +} - void OnClose(wxCloseEvent& event) { - CommitHeaderEdit(); - if (!ConfirmDirtyFileAction()) { - event.Veto(); - return; - } +void EditorPage::ShowPrintPreview() { + CommitHeaderEdit(); + CommitActiveEdit(); + if (m_printData.GetOrientation() != wxLANDSCAPE && m_printData.GetOrientation() != wxPORTRAIT) { + m_printData.SetOrientation(GuessLandscapeForPrint(BuildPrintableDocument()) ? wxLANDSCAPE : wxPORTRAIT); + } + ShowPrintPreviewWindow(this, BuildPrintableDocument(), m_printData); +} - if (m_findDialog) { - m_findDialog->Destroy(); - m_findDialog = nullptr; - } - event.Skip(); +void EditorPage::ShowPrint() { + CommitHeaderEdit(); + CommitActiveEdit(); + if (m_printData.GetOrientation() != wxLANDSCAPE && m_printData.GetOrientation() != wxPORTRAIT) { + m_printData.SetOrientation(GuessLandscapeForPrint(BuildPrintableDocument()) ? wxLANDSCAPE : wxPORTRAIT); } + ShowPrintDialogForDocument(this, BuildPrintableDocument(), m_printData); +} - void OnHeaderEditorEnter(wxCommandEvent&) { - CommitHeaderEdit(); - FocusFirstDataCellForEntry(); +void EditorPage::FocusEditor() { + if (m_headerEditor && m_headerEditor->IsShown()) { + m_headerEditor->SetFocus(); + return; } + if (m_grid) { + m_grid->SetFocus(); + } +} - void OnHeaderEditorKillFocus(wxFocusEvent& event) { - CommitHeaderEdit(); - event.Skip(); +void EditorPage::OnContextCopyRow(wxCommandEvent&) { + CopyRow(GetActiveRowIndex()); +} + +void EditorPage::OnContextCopyCell(wxCommandEvent&) { + CopyCell(GetActiveRowIndex(), GetActiveColumnIndex()); +} + +void EditorPage::OnInsertColumnBefore(wxCommandEvent&) { + const int col = GetActiveColumnIndex(); + if (col >= 0) { + InsertColumn(col); } +} - void OnHeaderEditorCharHook(wxKeyEvent& event) { - if (event.GetKeyCode() == WXK_ESCAPE) { - CancelHeaderEdit(); - m_grid->SetFocus(); - return; - } +void EditorPage::OnInsertColumnAfter(wxCommandEvent&) { + const int col = GetActiveColumnIndex(); + if (col >= 0) { + InsertColumn(col + 1); + } +} - if (event.GetKeyCode() == WXK_TAB) { - const int currentColumn = m_activeHeaderColumn; - CommitHeaderEdit(); +void EditorPage::OnInsertRowBefore(wxCommandEvent&) { + const int row = GetActiveRowIndex(); + if (row >= 0) { + InsertRow(row); + } +} - if (currentColumn < 0) { - m_grid->SetFocus(); - return; - } +void EditorPage::OnInsertRowAfter(wxCommandEvent&) { + const int row = GetActiveRowIndex(); + if (row >= 0) { + InsertRow(row + 1); + } +} - if (event.ShiftDown()) { - const int previousColumn = std::max(0, currentColumn - 1); - BeginHeaderEdit(previousColumn); - return; - } +void EditorPage::ShowContextMenu(const wxPoint& position) { + wxMenu menu; + menu.Append(ID_CONTEXT_COPY_ROW, "&Copy row"); + menu.Append(ID_CONTEXT_COPY_CELL, "Copy &cell"); + menu.AppendSeparator(); + menu.Append(ID_INSERT_ROW_BEFORE, "Insert row &before"); + menu.Append(ID_INSERT_ROW_AFTER, "Insert row &after"); + const bool hasTarget = m_contextRow >= 0; + menu.Enable(ID_CONTEXT_COPY_ROW, hasTarget); + menu.Enable(ID_CONTEXT_COPY_CELL, hasTarget && m_contextColumn >= 0); + menu.Enable(ID_INSERT_ROW_BEFORE, hasTarget); + menu.Enable(ID_INSERT_ROW_AFTER, hasTarget); + m_grid->PopupMenu(&menu, position); +} - if (currentColumn >= static_cast(GetColumnCount()) - 1) { - InsertColumn(currentColumn + 1); - return; - } +void EditorPage::ShowHeaderContextMenu(const wxPoint& position) { + wxMenu menu; + menu.Append(ID_INSERT_COLUMN_BEFORE, "Insert column &before"); + menu.Append(ID_INSERT_COLUMN_AFTER, "Insert column &after"); + const bool hasTarget = m_contextColumn >= 0; + menu.Enable(ID_INSERT_COLUMN_BEFORE, hasTarget); + menu.Enable(ID_INSERT_COLUMN_AFTER, hasTarget); + m_grid->GetGridColLabelWindow()->PopupMenu(&menu, position); +} - BeginHeaderEdit(currentColumn + 1); - return; - } +void EditorPage::OnCellLeftDClick(wxGridEvent& event) { + CommitHeaderEdit(); + SelectCell(static_cast(event.GetRow()), static_cast(event.GetCol())); + m_grid->EnableCellEditControl(); +} + +void EditorPage::OnCellRightClick(wxGridEvent& event) { + CancelHeaderEdit(); + m_contextRow = event.GetRow(); + m_contextColumn = event.GetCol(); + m_grid->SetGridCursor(m_contextRow, m_contextColumn); + ShowContextMenu(event.GetPosition()); +} +void EditorPage::OnLabelLeftDClick(wxGridEvent& event) { + if (event.GetRow() == -1 && event.GetCol() >= 0) { + BeginHeaderEdit(event.GetCol()); + return; + } + event.Skip(); +} + +void EditorPage::OnLabelRightClick(wxGridEvent& event) { + if (event.GetRow() == -1 && event.GetCol() >= 0) { + CommitActiveEdit(); + m_contextRow = -1; + m_contextColumn = event.GetCol(); + ShowHeaderContextMenu(event.GetPosition()); + return; + } + event.Skip(); +} + +void EditorPage::OnEditorShown(wxGridEvent& event) { + m_contextRow = event.GetRow(); + m_contextColumn = event.GetCol(); + event.Skip(); +} + +void EditorPage::OnCellChanged(wxGridEvent& event) { + if (!m_isRefreshingGrid) { + SyncCellFromGrid(event.GetRow(), event.GetCol()); + } + event.Skip(); +} + +void EditorPage::OnSelectCell(wxGridEvent& event) { + CommitHeaderEdit(); + event.Skip(); + NotifyStateChanged(); +} + +void EditorPage::OnGridCharHook(wxKeyEvent& event) { + if (m_activeHeaderColumn >= 0) { event.Skip(); + return; } - void OnGridResized(wxSizeEvent& event) { - RepositionHeaderEditor(); + if (!m_grid->IsCellEditControlShown()) { event.Skip(); + return; } - void OnGridWindowLeftDClick(wxMouseEvent& event) { - int logicalX = 0; - int logicalY = 0; - m_grid->CalcUnscrolledPosition(event.GetX(), event.GetY(), &logicalX, &logicalY); + const int keyCode = event.GetKeyCode(); + if (keyCode == WXK_RETURN || keyCode == WXK_NUMPAD_ENTER) { + CommitActiveEdit(); + return; + } - if (logicalY >= 0 && m_grid->YToRow(logicalY) == wxNOT_FOUND) { - AppendEmptyRow(); - if (m_grid->GetNumberCols() > 0) { - SelectCell(static_cast(m_grid->GetNumberRows() - 1), 0); - m_grid->EnableCellEditControl(); - } + if (keyCode == WXK_TAB && !event.ShiftDown()) { + CommitActiveEdit(); + MoveToNextEditableCell(); + return; + } + + event.Skip(); +} + +void EditorPage::OnHeaderEditorEnter(wxCommandEvent&) { + CommitHeaderEdit(); + FocusFirstDataCellForEntry(); +} + +void EditorPage::OnHeaderEditorKillFocus(wxFocusEvent& event) { + CommitHeaderEdit(); + event.Skip(); +} + +void EditorPage::OnHeaderEditorCharHook(wxKeyEvent& event) { + if (event.GetKeyCode() == WXK_ESCAPE) { + CancelHeaderEdit(); + m_grid->SetFocus(); + return; + } + + if (event.GetKeyCode() == WXK_TAB) { + const int currentColumn = m_activeHeaderColumn; + CommitHeaderEdit(); + + if (currentColumn < 0) { + m_grid->SetFocus(); return; } - event.Skip(); + if (event.ShiftDown()) { + BeginHeaderEdit(std::max(0, currentColumn - 1)); + return; + } + + if (currentColumn >= static_cast(GetColumnCount()) - 1) { + InsertColumn(currentColumn + 1); + return; + } + + BeginHeaderEdit(currentColumn + 1); + return; } -private: - wxGrid* m_grid{nullptr}; - wxTextCtrl* m_headerEditor{nullptr}; - std::vector> m_rows; - std::vector m_headers; - wxString m_currentFile; - wxFindReplaceData m_findData{wxFR_DOWN}; - wxFindReplaceDialog* m_findDialog{nullptr}; - wxPrintData m_printData; - size_t m_lastFindIndex{0}; - bool m_lastFindValid{false}; - bool m_isDirty{false}; - bool m_isRefreshingGrid{false}; - wxString m_documentName{"untitled.csv"}; - int m_contextRow{-1}; - int m_contextColumn{-1}; - int m_activeHeaderColumn{-1}; + event.Skip(); +} - wxDECLARE_EVENT_TABLE(); -}; +void EditorPage::OnGridResized(wxSizeEvent& event) { + RepositionHeaderEditor(); + event.Skip(); +} -wxBEGIN_EVENT_TABLE(MainFrame, wxFrame) - EVT_MENU(wxID_NEW, MainFrame::OnNew) - EVT_MENU(wxID_OPEN, MainFrame::OnOpen) - EVT_MENU(wxID_SAVE, MainFrame::OnSave) - EVT_MENU(wxID_SAVEAS, MainFrame::OnSaveAs) - EVT_MENU(wxID_PREVIEW, MainFrame::OnPrintPreview) - EVT_MENU(wxID_PRINT, MainFrame::OnPrint) - EVT_MENU(wxID_EXIT, MainFrame::OnExit) - EVT_MENU(wxID_COPY, MainFrame::OnCopy) - EVT_MENU(ID_GO_TO_FIRST, MainFrame::OnGoToFirst) - EVT_MENU(ID_GO_TO_LAST, MainFrame::OnGoToLast) - EVT_MENU(ID_GO_TO_ROW, MainFrame::OnGoToRow) - EVT_MENU(wxID_FIND, MainFrame::OnFind) - EVT_MENU(ID_FIND_NEXT, MainFrame::OnFindNext) - EVT_MENU(ID_FIND_PREVIOUS, MainFrame::OnFindPrevious) - EVT_MENU(wxID_ABOUT, MainFrame::OnAbout) - EVT_MENU(ID_CONTEXT_COPY_ROW, MainFrame::OnContextCopyRow) - EVT_MENU(ID_CONTEXT_COPY_CELL, MainFrame::OnContextCopyCell) - EVT_MENU(ID_INSERT_ROW_BEFORE, MainFrame::OnInsertRowBefore) - EVT_MENU(ID_INSERT_ROW_AFTER, MainFrame::OnInsertRowAfter) - EVT_MENU(ID_INSERT_COLUMN_BEFORE, MainFrame::OnInsertColumnBefore) - EVT_MENU(ID_INSERT_COLUMN_AFTER, MainFrame::OnInsertColumnAfter) - EVT_CLOSE(MainFrame::OnClose) - EVT_FIND(wxID_ANY, MainFrame::OnFindDialog) - EVT_FIND_NEXT(wxID_ANY, MainFrame::OnFindDialog) - EVT_FIND_CLOSE(wxID_ANY, MainFrame::OnFindDialogClose) -wxEND_EVENT_TABLE() +void EditorPage::OnGridWindowLeftDClick(wxMouseEvent& event) { + int logicalX = 0; + int logicalY = 0; + m_grid->CalcUnscrolledPosition(event.GetX(), event.GetY(), &logicalX, &logicalY); + + if (logicalY >= 0 && m_grid->YToRow(logicalY) == wxNOT_FOUND) { + AppendEmptyRow(); + if (m_grid->GetNumberCols() > 0) { + SelectCell(static_cast(m_grid->GetNumberRows() - 1), 0); + m_grid->EnableCellEditControl(); + } + return; + } + + event.Skip(); +} + +void EditorPage::OnFindDialog(wxFindDialogEvent& event) { + m_findData.SetFindString(event.GetFindString()); + const bool forward = (event.GetFlags() & wxFR_DOWN) != 0; + if (!FindInData(forward)) { + wxMessageBox("No matching data found.", "Find", wxOK | wxICON_INFORMATION, this); + } +} + +void EditorPage::OnFindDialogClose(wxFindDialogEvent&) { + if (m_findDialog) { + m_findDialog->Destroy(); + m_findDialog = nullptr; + } +} wxFrame* CreateMainFrame(const wxString& initialFile) { return new MainFrame(initialFile); @@ -1387,8 +1890,9 @@ wxFrame* CreateMainFrame(const wxString& initialFile) { bool OpenFileInMainFrame(wxFrame* frame, const wxString& path) { auto* mainFrame = dynamic_cast(frame); if (!mainFrame) { - return false; + CreateAndShowMainFrame(path); + return true; } - return mainFrame->OpenDocumentFile(path); + return mainFrame->OpenDocumentPath(path); }