Refactor outline navigation

This commit is contained in:
1024jp 2020-08-12 23:28:21 +09:00
parent 964538b819
commit 56440e9868
6 changed files with 153 additions and 27 deletions

View File

@ -346,6 +346,7 @@
2A78BFB31D1B240900A583D2 /* UpdaterManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A78BFB21D1B240900A583D2 /* UpdaterManager.swift */; };
2A78BFBC1D1B376000A583D2 /* ServicesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A78BFBB1D1B376000A583D2 /* ServicesProvider.swift */; };
2A78BFBD1D1B376000A583D2 /* ServicesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A78BFBB1D1B376000A583D2 /* ServicesProvider.swift */; };
2A7B279924E435FE00F02304 /* OutlineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A7B279824E435FE00F02304 /* OutlineTests.swift */; };
2A80C65E1CEE33C100AA664D /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = 2A80C65C1CEE33C100AA664D /* Credits.html */; };
2A80C65F1CEE33C100AA664D /* Credits.html in Resources */ = {isa = PBXBuildFile; fileRef = 2A80C65C1CEE33C100AA664D /* Credits.html */; };
2A80C6681CEE540F00AA664D /* Acknowledgments.html in Resources */ = {isa = PBXBuildFile; fileRef = 2A80C6661CEE540F00AA664D /* Acknowledgments.html */; };
@ -1105,6 +1106,7 @@
2A78BFAF1D1B168E00A583D2 /* WebDocumentViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebDocumentViewController.swift; sourceTree = "<group>"; };
2A78BFB21D1B240900A583D2 /* UpdaterManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UpdaterManager.swift; sourceTree = "<group>"; };
2A78BFBB1D1B376000A583D2 /* ServicesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ServicesProvider.swift; sourceTree = "<group>"; };
2A7B279824E435FE00F02304 /* OutlineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutlineTests.swift; sourceTree = "<group>"; };
2A80C65D1CEE33C100AA664D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = en; path = en.lproj/Credits.html; sourceTree = "<group>"; };
2A80C6601CEE351200AA664D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = ja; path = ja.lproj/Credits.html; sourceTree = "<group>"; };
2A80C6611CEE351400AA664D /* de */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = de; path = de.lproj/Credits.html; sourceTree = "<group>"; };
@ -2205,6 +2207,7 @@
2AED46721E43942300751C45 /* TextFindTests.swift */,
2A7135821CFFDC6600ADA555 /* FilePermissionTests.swift */,
2A1893AC1FFF6A0100AD244F /* LineSortTests.swift */,
2A7B279824E435FE00F02304 /* OutlineTests.swift */,
2A5EDDBC241B64EB00A07810 /* TextClippingTests.swift */,
2A64A2352387754000646BE4 /* UserDefaultsObservationTests.swift */,
2A719F6523CD92370026F877 /* FuzzyRangeTests.swift */,
@ -3038,6 +3041,7 @@
2ABFF6D71D02856A00BE2795 /* ShortcutTests.swift in Sources */,
2A643BB3245172EB00B2AD54 /* NSBezierPathTests.swift in Sources */,
2A476CB11D09D0500088E37A /* FontExtensionTests.swift in Sources */,
2A7B279924E435FE00F02304 /* OutlineTests.swift in Sources */,
2AA2E0261C0454730087BDD6 /* StringIndentationTests.swift in Sources */,
2A476CAE1D09C8C80088E37A /* URLExtensionsTests.swift in Sources */,
2AC71DE21BF0BDBC002E1434 /* StringExtensionsTests.swift in Sources */,

View File

@ -68,4 +68,15 @@ extension NSTextView {
return (indentLocation < location) ? indentLocation : lineRange.location
}
/// Select the given range with visual feedback.
///
/// - Parameter range: The character range to select.
func select(range: NSRange) {
self.selectedRange = range
self.centerSelectionInVisibleArea(self)
self.window?.makeFirstResponder(self)
}
}

View File

@ -151,18 +151,18 @@ final class NavigationBarController: NSViewController {
/// Can select the prev item in outline menu?
var canSelectPrevItem: Bool {
guard let menu = self.outlineMenu else { return false }
guard let textView = self.textView else { return false }
return (menu.indexOfSelectedItem > 1)
return self.outlineItems.previousItem(for: textView.selectedRange) != nil
}
/// Can select the next item in outline menu?
var canSelectNextItem: Bool {
guard let menu = self.outlineMenu else { return false }
guard let textView = self.textView else { return false }
return menu.itemArray[(menu.indexOfSelectedItem + 1)...].contains { $0.representedObject != nil }
return self.outlineItems.nextItem(for: textView.selectedRange) != nil
}
@ -182,39 +182,35 @@ final class NavigationBarController: NSViewController {
@IBAction func selectOutlineMenuItem(_ sender: NSMenuItem) {
guard
let range = sender.representedObject as? NSRange,
let textView = self.textView
let textView = self.textView,
let range = sender.representedObject as? NSRange
else { return assertionFailure() }
textView.selectedRange = range
textView.centerSelectionInVisibleArea(self)
textView.window?.makeFirstResponder(textView)
textView.select(range: range)
}
/// Select the previous outline menu item.
@IBAction func selectPrevItemOfOutlineMenu(_ sender: Any?) {
guard let popUp = self.outlineMenu, self.canSelectPrevItem else { return }
guard
let textView = self.textView,
let item = self.outlineItems.previousItem(for: textView.selectedRange)
else { return }
let index = popUp.itemArray[..<popUp.indexOfSelectedItem]
.lastIndex { $0.representedObject is NSRange } ?? 0
popUp.menu!.performActionForItem(at: index)
textView.select(range: item.range)
}
/// Select the next outline menu item.
@IBAction func selectNextItemOfOutlineMenu(_ sender: Any?) {
guard let popUp = self.outlineMenu, self.canSelectNextItem else { return }
guard
let textView = self.textView,
let item = self.outlineItems.nextItem(for: textView.selectedRange)
else { return }
let index = popUp.itemArray[(popUp.indexOfSelectedItem + 1)...]
.firstIndex { $0.representedObject is NSRange }
if let index = index {
popUp.menu!.performActionForItem(at: index)
}
textView.select(range: item.range)
}

View File

@ -77,9 +77,44 @@ extension OutlineItem {
extension BidirectionalCollection where Element == OutlineItem {
func indexOfItem(for characterRange: NSRange, allowsSeparator: Bool = true) -> Index? {
/// Return the index of element for the given range.
///
/// - Parameter range: The character range to refer.
/// - Returns: The index of the corresponding outline item, or `nil` if not exist.
func indexOfItem(at location: Int, allowsSeparator: Bool = false) -> Index? {
return self.lastIndex { $0.range.location <= characterRange.location && (allowsSeparator || !$0.isSeparator ) }
return self.lastIndex { $0.range.location <= location && (allowsSeparator || !$0.isSeparator ) }
}
/// Return the previous non-separator element from the given range.
///
/// - Parameter range: The character range to refer.
/// - Returns: The previous outline item, or `nil` if not exist.
func previousItem(for range: NSRange) -> OutlineItem? {
guard let currentIndex = self.indexOfItem(at: range.lowerBound) else { return nil }
return self[..<currentIndex].last { !$0.isSeparator }
}
/// Return the next non-separator element from the given range.
///
/// - Parameter range: The character range to refer.
/// - Returns: The next outline item, or `nil` if not exist.
func nextItem(for range: NSRange) -> OutlineItem? {
if let first = self.first(where: { !$0.isSeparator }), range.upperBound < first.range.location {
return first
}
guard
let currentIndex = self.indexOfItem(at: range.upperBound),
currentIndex <= self.endIndex
else { return nil }
return self[self.index(after: currentIndex)...].first { !$0.isSeparator }
}
}

View File

@ -174,9 +174,7 @@ final class OutlineViewController: NSViewController {
textView.string.nsRange.upperBound >= item.range.upperBound
else { return }
textView.selectedRange = item.range
textView.scrollRangeToVisible(item.range)
textView.showFindIndicator(for: item.range)
textView.select(range: item.range)
}
@ -220,7 +218,7 @@ final class OutlineViewController: NSViewController {
guard
let textView = textView ?? self.document?.textView,
let row = self.outlineItems.indexOfItem(for: textView.selectedRange, allowsSeparator: false),
let row = self.outlineItems.indexOfItem(at: textView.selectedRange.location),
outlineView.numberOfRows > row
else { return outlineView.deselectAll(nil) }

82
Tests/OutlineTests.swift Normal file
View File

@ -0,0 +1,82 @@
//
// OutlineTests.swift
//
// CotEditor
// https://coteditor.com
//
// Created by 1024jp on 2020-08-12.
//
// ---------------------------------------------------------------------------
//
// © 2020 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import CotEditor
final class OutlineTests: XCTestCase {
private let items: [OutlineItem] = [
OutlineItem(title: "a", range: NSRange(location: 10, length: 5)), // 0
OutlineItem(title: .separator, range: NSRange(location: 20, length: 5)),
OutlineItem(title: .separator, range: NSRange(location: 30, length: 5)),
OutlineItem(title: "b", range: NSRange(location: 40, length: 5)), // 3
OutlineItem(title: .separator, range: NSRange(location: 50, length: 5)),
OutlineItem(title: "c", range: NSRange(location: 60, length: 5)), // 5
OutlineItem(title: .separator, range: NSRange(location: 70, length: 5)),
]
private let emptyItems: [OutlineItem] = []
func testIndex() throws {
XCTAssertNil(self.emptyItems.indexOfItem(at: 10))
XCTAssertNil(self.items.indexOfItem(at: 9))
XCTAssertEqual(self.items.indexOfItem(at: 10), 0)
XCTAssertEqual(self.items.indexOfItem(at: 18), 0)
XCTAssertEqual(self.items.indexOfItem(at: 20), 0)
XCTAssertEqual(self.items.indexOfItem(at: 40), 3)
XCTAssertEqual(self.items.indexOfItem(at: 50), 3)
XCTAssertEqual(self.items.indexOfItem(at: 59), 3)
XCTAssertEqual(self.items.indexOfItem(at: 60), 5)
}
func testPreviousItem() throws {
XCTAssertNil(self.emptyItems.previousItem(for: NSRange(10..<20)))
XCTAssertNil(self.items.previousItem(for: NSRange(10..<20)))
XCTAssertNil(self.items.previousItem(for: NSRange(19..<19)))
XCTAssertEqual(self.items.previousItem(for: NSRange(59..<70)), items[0])
XCTAssertEqual(self.items.previousItem(for: NSRange(60..<70)), items[3])
}
func testNextItem() throws {
XCTAssertNil(self.emptyItems.nextItem(for: NSRange(10..<20)))
XCTAssertEqual(self.items.nextItem(for: NSRange(0..<0)), items[0])
XCTAssertEqual(self.items.nextItem(for: NSRange(0..<10)), items[3])
XCTAssertEqual(self.items.nextItem(for: NSRange(40..<40)), items[5])
XCTAssertNil(self.items.nextItem(for: NSRange(60..<60)))
XCTAssertNil(self.items.nextItem(for: NSRange(40..<61)))
}
}