-- Copyright 2011-14 Paul Kulchenko, ZeroBrane LLC -- authors: Lomtik Software (J. Winwood & John Labenski) -- Luxinia Dev (Eike Decker & Christoph Kubisch) --------------------------------------------------------- local ide = ide ide.findReplace = { dialog = nil, -- the wxDialog for find/replace replace = false, -- is it a find or replace dialog infiles = false, fWholeWord = false, -- match whole words fMatchCase = false, -- case sensitive fRegularExpr = false, -- use regex fWrap = true, -- search wraps around fDown = true, -- search downwards in doc fSubDirs = true, -- search in subdirectories fMakeBak = true, -- make bak files for replace in files buttons = {}, findTextArray = {}, -- array of last entered find text findText = "", -- string to find replaceTextArray = {}, -- array of last entered replace text replaceText = "", -- string to replace find string with filemaskText = nil, filemaskTextArray= {}, filedirText = "", filedirTextArray = {}, foundString = false, -- was the string found for the last search oveditor = nil, curfilename = "", -- for search in files occurrences = 0, -- HasText() is there a string to search for -- GetSelectedString() get currently selected string if it's on one line -- FindString(reverse) find the findText string -- Show(replace) create the dialog -- GetEditor() which editor to use } local findReplace = ide.findReplace local lastEditor function findReplace:GetEditor() lastEditor = findReplace.oveditor or GetEditorWithFocus() or lastEditor -- last editor may already be "userdata" instead of a Scintilla object, -- so check if this is still a valid wxSTC object return pcall(function() lastEditor:GetId() end) and lastEditor or GetEditor() end -------------------- Find replace dialog local function setSearchFlags(editor) local flags = wxstc.wxSTC_FIND_POSIX if findReplace.fWholeWord then flags = flags + wxstc.wxSTC_FIND_WHOLEWORD end if findReplace.fMatchCase then flags = flags + wxstc.wxSTC_FIND_MATCHCASE end if findReplace.fRegularExpr then flags = flags + wxstc.wxSTC_FIND_REGEXP end editor:SetSearchFlags(flags) end local function setTarget(editor, fDown, fAll, fWrap) local selStart = editor:GetSelectionStart() local selEnd = editor:GetSelectionEnd() local len = editor:GetLength() local s, e if fDown then s = iff(fAll, selStart, selEnd) e = len else s = 0 e = iff(fAll, selEnd, selStart) end -- if going up and not search/replace All, then switch the range to -- allow the next match to be properly marked if not fDown and not fAll then s, e = e, s end -- if wrap around and search all requested, then search the entire document if fAll and fWrap then s, e = 0, len end editor:SetTargetStart(s) editor:SetTargetEnd(e) return e end local function setTargetAll(editor) local s = 0 local e = editor:GetLength() editor:SetTargetStart(s) editor:SetTargetEnd(e) return e end function findReplace:HasText() return (findReplace.findText ~= nil) and (string.len(findReplace.findText) > 0) end function findReplace:GetSelectedString() local editor = findReplace:GetEditor() if editor then local startSel = editor:GetSelectionStart() local endSel = editor:GetSelectionEnd() if (startSel ~= endSel) and (editor:LineFromPosition(startSel) == editor:LineFromPosition(endSel)) then findReplace.findText = editor:GetTextRange(startSel, endSel) return true end end return false end local function shake(window, shakes, duration, vigour) shakes = shakes or 4 duration = duration or 0.5 vigour = vigour or 0.05 if not window then return end local delay = math.floor(duration/shakes/2) local position = window:GetPosition() -- get current position local deltax = window:GetSize():GetWidth()*vigour for s = 1, shakes do window:Move(position:GetX()-deltax, position:GetY()) wx.wxMilliSleep(delay) window:Move(position:GetX()+deltax, position:GetY()) wx.wxMilliSleep(delay) end window:Move(position) -- restore position end function findReplace:FindString(reverse) if findReplace:HasText() then local editor = findReplace:GetEditor() local fDown = iff(reverse, not findReplace.fDown, findReplace.fDown) setSearchFlags(editor) setTarget(editor, fDown) local posFind = editor:SearchInTarget(findReplace.findText) if (posFind == -1) and findReplace.fWrap then editor:SetTargetStart(iff(fDown, 0, editor:GetLength())) editor:SetTargetEnd(iff(fDown, editor:GetLength(), 0)) posFind = editor:SearchInTarget(findReplace.findText) end if posFind == -1 then findReplace.foundString = false ide.frame:SetStatusText(TR("Text not found.")) shake(findReplace.dialog) else findReplace.foundString = true local start = editor:GetTargetStart() local finish = editor:GetTargetEnd() editor:EnsureVisibleEnforcePolicy(editor:LineFromPosition(start)) editor:SetSelection(start, finish) ide.frame:SetStatusText("") end end end -- returns if something was found -- [inFileRegister(pos)] passing function will -- register every position item was found -- supposed for "Search/Replace in Files" function findReplace:FindStringAll(inFileRegister) local found = false if findReplace:HasText() then local findLen = string.len(findReplace.findText) local editor = findReplace:GetEditor() local e = setTargetAll(editor) setSearchFlags(editor) local posFind = editor:SearchInTarget(findReplace.findText) if (posFind ~= -1) then while posFind ~= -1 do inFileRegister(posFind) editor:SetTargetStart(posFind + findLen) editor:SetTargetEnd(e) posFind = editor:SearchInTarget(findReplace.findText) end found = true end end return found end -- returns if replacements were done -- [inFileRegister(pos)] passing function will disable "undo" -- registers every position item was found -- supposed for "Search/Replace in Files" function findReplace:ReplaceString(fReplaceAll, inFileRegister) local replaced = false if findReplace:HasText() then local editor = findReplace:GetEditor() -- don't replace in read-only editors if editor:GetReadOnly() then return false end local endTarget = inFileRegister and setTargetAll(editor) or setTarget(editor, findReplace.fDown, fReplaceAll, findReplace.fWrap) if fReplaceAll then setSearchFlags(editor) local occurrences = 0 local posFind = editor:SearchInTarget(findReplace.findText) if (posFind ~= -1) then if (not inFileRegister) then editor:BeginUndoAction() end while posFind ~= -1 do if (inFileRegister) then inFileRegister(posFind) end local length = editor:GetLength() local replaced = findReplace.fRegularExpr and editor:ReplaceTargetRE(findReplace.replaceText) or editor:ReplaceTarget(findReplace.replaceText) editor:SetTargetStart(posFind + replaced) -- adjust the endTarget as the position could have changed; -- can't simply subtract findText length as it could be a regexp endTarget = endTarget + (editor:GetLength() - length) editor:SetTargetEnd(endTarget) posFind = editor:SearchInTarget(findReplace.findText) occurrences = occurrences + 1 end if (not inFileRegister) then editor:EndUndoAction() end replaced = true end ide.frame:SetStatusText(("%s %s."):format( TR("Replaced"), TR("%d instance", occurrences):format(occurrences))) else -- check if there is anything selected as well as the user can -- move the cursor after successful search if findReplace.foundString and editor:GetSelectionStart() ~= editor:GetSelectionEnd() then local start = editor:GetSelectionStart() -- convert selection to target as we need TargetRE support editor:TargetFromSelection() local length = editor:GetLength() local replaced = findReplace.fRegularExpr and editor:ReplaceTargetRE(findReplace.replaceText) or editor:ReplaceTarget(findReplace.replaceText) editor:SetSelection(start, start + replaced) findReplace.foundString = false replaced = true end findReplace:FindString() end end return replaced end local function onFileRegister(pos) local editor = findReplace.oveditor local line = editor:LineFromPosition(pos) local linepos = pos - editor:PositionFromLine(line) local result = "("..(line+1)..","..(linepos+1).."): "..editor:GetLine(line) DisplayOutputLn(findReplace.curfilename..result:gsub("\r?\n$","")) findReplace.occurrences = findReplace.occurrences + 1 end local function ProcInFiles(startdir,mask,subdirs,replace) local files = FileSysGetRecursive(startdir,subdirs,"*") local start = TimeGet() -- mask could be a list, so generate a table with matching patterns -- accept "*.lua; .txt;.wlua" combinations local masks = {} for m in mask:gmatch("[^%s;]+") do table.insert(masks, m:gsub("%.", "%%."):gsub("%*", ".*").."$") end for _,file in ipairs(files) do -- ignore .bak files when replacing and asked to store .bak files -- and skip folders as these are included in the list as well if not (replace and findReplace.fMakeBak and file:find('.bak$')) and not IsDirectory(file) then local match = false for _, mask in ipairs(masks) do match = match or file:find(mask) end if match then findReplace.curfilename = file local filetext = FileRead(file) if filetext and not isBinary(filetext:sub(1, 2048)) then findReplace.oveditor:SetText(filetext) if replace then -- check if anything replaced, store changed content, make .bak if findReplace:ReplaceString(true,onFileRegister) and (not findReplace.fMakeBak or FileWrite(file..".bak",filetext)) then FileWrite(file,findReplace.oveditor:GetText()) end else findReplace:FindStringAll(onFileRegister) end -- give time to the UI to refresh if TimeGet() - start > 0.25 then ide:Yield() end if not findReplace.dialog:IsShown() then DisplayOutputLn(TR("Cancelled by the user.")) break end end end end end end function findReplace:RunInFiles(replace) if not findReplace:HasText() then return end findReplace.oveditor = wxstc.wxStyledTextCtrl(findReplace.dialog, wx.wxID_ANY, wx.wxDefaultPosition, wx.wxSize(1,1), wx.wxBORDER_NONE) findReplace.occurrences = 0 ActivateOutput() local startdir = findReplace.filedirText DisplayOutputLn(("%s '%s'."):format( (replace and TR("Replacing") or TR("Searching for")), findReplace.findText)) ProcInFiles(startdir, findReplace.filemaskText, findReplace.fSubDirs, replace) DisplayOutputLn(("%s %s."):format( (replace and TR("Replaced") or TR("Found")), TR("%d instance", findReplace.occurrences):format(findReplace.occurrences))) findReplace.oveditor = nil end local function getExts() local knownexts = {} for i,spec in pairs(ide.specs) do if (spec.exts) then for n,ext in ipairs(spec.exts) do table.insert(knownexts, "*."..ext) end end end return #knownexts > 0 and table.concat(knownexts, "; ") or nil end function findReplace:createDialog(replace,infiles) local ID_FIND_NEXT = 1 local ID_REPLACE = 2 local ID_REPLACE_ALL = 3 local ID_SETDIR = 4 local mac = ide.osname == 'Macintosh' local findReplace = self local position = wx.wxDefaultPosition if findReplace.dialog then -- grab current position before destroying the dialog position = findReplace.dialog:GetPosition() findReplace.dialog:Destroy() end local findDialog = wx.wxDialog(ide.frame, wx.wxID_ANY, infiles and TR("Find In Files") or TR("Find"), position, wx.wxDefaultSize, wx.wxDEFAULT_DIALOG_STYLE) findReplace.replace = replace findReplace.infiles = infiles -- Create right hand buttons and sizer local findButton = wx.wxButton(findDialog, ID_FIND_NEXT, infiles and TR("&Find All") or TR("&Find Next")) local replaceButton = wx.wxButton(findDialog, ID_REPLACE, infiles and replace and TR("&Replace All") or TR("&Replace")) local replaceAllButton = nil if (replace and not infiles) then replaceAllButton = wx.wxButton(findDialog, ID_REPLACE_ALL, TR("Replace A&ll")) end local cancelButton = wx.wxButton(findDialog, wx.wxID_CANCEL, TR("Cancel")) local buttonsSizer = wx.wxBoxSizer(wx.wxVERTICAL) buttonsSizer:Add(findButton, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) buttonsSizer:Add(replaceButton, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) if replaceAllButton then buttonsSizer:Add(replaceAllButton, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) end buttonsSizer:Add(cancelButton, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) -- Create find/replace text entry sizer local findStatText = wx.wxStaticText(findDialog, wx.wxID_ANY, TR("Find")..": ") local findTextCombo = wx.wxComboBox(findDialog, wx.wxID_ANY, findReplace.findText, wx.wxDefaultPosition, wx.wxDefaultSize, findReplace.findTextArray, wx.wxCB_DROPDOWN + (mac and wx.wxTE_PROCESS_ENTER or 0)) findTextCombo:SetFocus() local infilesMaskStat,infilesMaskCombo local infilesDirStat,infilesDirCombo,infilesDirButton if (infiles) then infilesMaskStat = wx.wxStaticText(findDialog, wx.wxID_ANY, TR("File Type")..": ") infilesMaskCombo = wx.wxComboBox(findDialog, wx.wxID_ANY, findReplace.filemaskText or getExts() or "*.*", wx.wxDefaultPosition, wx.wxDefaultSize, findReplace.filemaskTextArray) local fname = GetEditorFileAndCurInfo(true) if #(findReplace.filedirText) == 0 then findReplace.filedirText = ide.config.path.projectdir or fname and fname:GetPath(wx.wxPATH_GET_VOLUME) or "" end infilesDirStat = wx.wxStaticText(findDialog, wx.wxID_ANY, TR("Directory")..": ") infilesDirCombo = wx.wxComboBox(findDialog, wx.wxID_ANY, findReplace.filedirText, wx.wxDefaultPosition, wx.wxDefaultSize, findReplace.filedirTextArray) infilesDirButton = wx.wxButton(findDialog, ID_SETDIR, "...", wx.wxDefaultPosition, wx.wxSize(26,20)) end local replaceStatText, replaceTextCombo if (replace) then replaceStatText = wx.wxStaticText(findDialog, wx.wxID_ANY, TR("Replace")..": ") replaceTextCombo = wx.wxComboBox(findDialog, wx.wxID_ANY, findReplace.replaceText, wx.wxDefaultPosition, wx.wxDefaultSize, findReplace.replaceTextArray, wx.wxCB_DROPDOWN + (mac and wx.wxTE_PROCESS_ENTER or 0)) end local findReplaceSizer = wx.wxFlexGridSizer(2, 3, 0, 0) findReplaceSizer:AddGrowableCol(1) findReplaceSizer:Add(findStatText, 0, wx.wxALL + wx.wxALIGN_RIGHT + wx.wxALIGN_CENTER_VERTICAL, 0) findReplaceSizer:Add(findTextCombo, 1, wx.wxALL + wx.wxGROW + wx.wxCENTER+ wx.wxALIGN_CENTER_VERTICAL, 0) findReplaceSizer:Add(16, 8, 0, wx.wxALL + wx.wxALIGN_RIGHT + wx.wxADJUST_MINSIZE,0) if (infiles) then findReplaceSizer:Add(infilesMaskStat, 0, wx.wxTOP + wx.wxALIGN_RIGHT + wx.wxALIGN_CENTER_VERTICAL, 5) findReplaceSizer:Add(infilesMaskCombo, 1, wx.wxTOP + wx.wxGROW + wx.wxCENTER+ wx.wxALIGN_CENTER_VERTICAL, 5) findReplaceSizer:Add(16, 8, 0, wx.wxTOP, 5) findReplaceSizer:Add(infilesDirStat, 0, wx.wxTOP + wx.wxALIGN_RIGHT + wx.wxALIGN_CENTER_VERTICAL, 5) findReplaceSizer:Add(infilesDirCombo, 1, wx.wxTOP + wx.wxGROW + wx.wxCENTER+ wx.wxALIGN_CENTER_VERTICAL, 5) findReplaceSizer:Add(infilesDirButton, 0, wx.wxTOP + wx.wxALIGN_RIGHT + wx.wxADJUST_MINSIZE+ wx.wxALIGN_CENTER_VERTICAL, 5) end if (replace) then findReplaceSizer:Add(replaceStatText, 0, wx.wxTOP + wx.wxALIGN_RIGHT + wx.wxALIGN_CENTER_VERTICAL, 5) findReplaceSizer:Add(replaceTextCombo, 1, wx.wxTOP + wx.wxGROW + wx.wxCENTER+ wx.wxALIGN_CENTER_VERTICAL, 5) end -- the StaticBox(Sizer) needs to be created before checkboxes, otherwise -- checkboxes don't get any clicks on OSX (ide.osname == 'Macintosh') -- as the z-order for event traversal appears to be incorrect. local optionsSizer = wx.wxStaticBoxSizer(wx.wxVERTICAL, findDialog, TR("Options")) -- Create find/replace option checkboxes local wholeWordCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR("Match &whole word")) local matchCaseCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR("Match &case")) local wrapAroundCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR("Wrap ar&ound")) local regexCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR("Regular &expression")) wholeWordCheckBox:SetValue(findReplace.fWholeWord) matchCaseCheckBox:SetValue(findReplace.fMatchCase) wrapAroundCheckBox:SetValue(findReplace.fWrap) regexCheckBox:SetValue(findReplace.fRegularExpr) local optionSizer = wx.wxBoxSizer(wx.wxVERTICAL, findDialog) optionSizer:Add(wholeWordCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) optionSizer:Add(matchCaseCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) optionSizer:Add(wrapAroundCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) optionSizer:Add(regexCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) optionsSizer:Add(optionSizer, 0, 0, 5) -- Create scope radiobox local scopeRadioBox local subDirCheckBox local makeBakCheckBox local scopeSizer if (infiles) then -- the StaticBox(Sizer) needs to be created before checkboxes scopeSizer = wx.wxStaticBoxSizer(wx.wxVERTICAL, findDialog, TR("In Files")) subDirCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR("&Subdirectories")) makeBakCheckBox = wx.wxCheckBox(findDialog, wx.wxID_ANY, TR(".&bak on Replace")) subDirCheckBox:SetValue(findReplace.fSubDirs) makeBakCheckBox:SetValue(findReplace.fMakeBak) local optionSizer = wx.wxBoxSizer(wx.wxVERTICAL, findDialog) optionSizer:Add(subDirCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) optionSizer:Add(makeBakCheckBox, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 3) scopeSizer:Add(optionSizer, 0, 0, 5) else scopeRadioBox = wx.wxRadioBox(findDialog, wx.wxID_ANY, TR("Scope"), wx.wxDefaultPosition, wx.wxDefaultSize, {TR("&Up"), TR("&Down")}, 1, wx.wxRA_SPECIFY_COLS) scopeRadioBox:SetSelection(iff(findReplace.fDown, 1, 0)) scopeSizer = wx.wxBoxSizer(wx.wxVERTICAL, findDialog) scopeSizer:Add(scopeRadioBox, 0, 0, 0) end -- Add all the sizers to the dialog local optionScopeSizer = wx.wxBoxSizer(wx.wxHORIZONTAL) optionScopeSizer:Add(optionsSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 5) optionScopeSizer:Add(scopeSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 5) local leftSizer = wx.wxBoxSizer(wx.wxVERTICAL) leftSizer:Add(findReplaceSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 0) leftSizer:Add(optionScopeSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 0) local mainSizer = wx.wxBoxSizer(wx.wxHORIZONTAL) mainSizer:Add(leftSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 10) mainSizer:Add(buttonsSizer, 0, wx.wxALL + wx.wxGROW + wx.wxCENTER, 10) mainSizer:SetSizeHints(findDialog) findDialog:SetSizer(mainSizer) local function TransferDataFromWindow() findReplace.fWholeWord = wholeWordCheckBox:GetValue() findReplace.fMatchCase = matchCaseCheckBox:GetValue() findReplace.fWrap = wrapAroundCheckBox:GetValue() if (findReplace.infiles) then findReplace.fSubDirs = subDirCheckBox:GetValue() findReplace.fMakeBak = makeBakCheckBox:GetValue() else findReplace.fDown = scopeRadioBox:GetSelection() == 1 end findReplace.fRegularExpr = regexCheckBox:GetValue() findReplace.findText = findTextCombo:GetValue() PrependStringToArray(findReplace.findTextArray, findReplace.findText) if findReplace.replace then findReplace.replaceText = replaceTextCombo:GetValue() PrependStringToArray(findReplace.replaceTextArray, findReplace.replaceText) end if findReplace.infiles then findReplace.filemaskText = infilesMaskCombo:GetValue() PrependStringToArray(findReplace.filemaskTextArray, findReplace.filemaskText) findReplace.filedirText = infilesDirCombo:GetValue() PrependStringToArray(findReplace.filedirTextArray, findReplace.filedirText) end return true end -- this is a workaround for Enter issue in wxComboBox on OSX: -- https://groups.google.com/d/msg/wx-users/EVJr8GqyNUA/CUALp585E78J if (mac and ide.wxver >= "2.9.5") then local function simulateEnter() findDialog:AddPendingEvent(wx.wxCommandEvent( wx.wxEVT_COMMAND_BUTTON_CLICKED, ID_FIND_NEXT)) end findTextCombo:Connect(wx.wxEVT_COMMAND_TEXT_ENTER, simulateEnter) if replace then replaceTextCombo:Connect(wx.wxEVT_COMMAND_TEXT_ENTER, simulateEnter) end end findDialog:Connect(ID_FIND_NEXT, wx.wxEVT_COMMAND_BUTTON_CLICKED, function() TransferDataFromWindow() if (findReplace.infiles) then for _, b in pairs(findReplace.buttons) do b:Disable() end findReplace:RunInFiles() findReplace.dialog:Destroy() findReplace.dialog = nil else findReplace:FindString() end end) findDialog:Connect(ID_REPLACE, wx.wxEVT_COMMAND_BUTTON_CLICKED, function(event) TransferDataFromWindow() event:Skip() if findReplace.replace then if (findReplace.infiles) then for _, b in pairs(findReplace.buttons) do b:Disable() end findReplace:RunInFiles(true) findReplace.dialog:Destroy() findReplace.dialog = nil else findReplace:ReplaceString() end else findReplace:createDialog(true,infiles) end end) if replaceAllButton then findDialog:Connect(ID_REPLACE_ALL, wx.wxEVT_COMMAND_BUTTON_CLICKED, function(event) TransferDataFromWindow() event:Skip() findReplace:ReplaceString(true) end) end if infilesDirButton then findDialog:Connect(ID_SETDIR, wx.wxEVT_COMMAND_BUTTON_CLICKED, function() local filePicker = wx.wxDirDialog(findDialog, TR("Choose a project directory"), findReplace.filedirText~="" and findReplace.filedirText or wx.wxGetCwd(),wx.wxFLP_USE_TEXTCTRL) local res = filePicker:ShowModal(true) if res == wx.wxID_OK then infilesDirCombo:SetValue(FixDir(filePicker:GetPath())) end -- when the dropdown is used to select the directory on OSX, -- the find dialog moves to the background; this keeps it on top. if ide.osname == 'Macintosh' then findDialog:Raise() end end) end -- reset search when re-creating dialog to avoid modifying selected -- fragment after successful search and updated replacement findReplace.foundString = false findReplace.dialog = findDialog findDialog:Show(true) -- if on OSX then select the current value of the default dropdown -- and don't set the default as it doesn't make Enter to work, but -- prevents associated hotkey (Cmd-F) from working (wx2.9.5). -- SetFocus has to be done after :Show on OSX as it doesn't put -- the focus on the Find field on some instances of OSX 10.9.2. if mac then findTextCombo:SetSelection(-1, -1) findTextCombo:SetFocus() -- force focus on the Find else findButton:SetDefault() end findReplace.buttons = {findButton, replaceButton} return findDialog end function findReplace:Show(replace,infiles) self:GetSelectedString() self:createDialog(replace,infiles) end