feed

2024年09月30日, 編集履歴

Murasaki ver. 2.5をリリース

macOS用EPUBリーダアプリ「Murasaki」のver. 2.5が出ました。

ver. 2.5での変更点は、

です。

新しいQuick Look拡張機能に関して、macOS 15 Sequoia以降は従来のQuick Lookプラグイン形式(.qlgenerator)がサポートされなくなったので、Sequoia以降で動作する機能拡張形式(.appex)で実装し直しました。

それに伴い、Quick Lookの動作設定を変更できるようにもなりました。

それぞれを、Murasaki本体の設定ウィンドウから変更できるようになっています。

読み込みデータサイズ制限は、Quick LookがEPUBデータを展開、プレヴューを構成していくなかで、全データを読み込むのではなく、設定したデータサイズで読み込みを打ち切ります。大きなデータサイズのEPUBの場合、プレヴュー構成に時間が掛かることがあるための制限となります(これまでは固定値でした)。

デフォルトのコンテントサイズは、Quick Lookプレヴューの初期ウィンドウサイズを設定できます。

また、新しい拡張機能形式はシステム設定から拡張機能の有効・無効を設定することができます。

どうぞよろしくお願いします。

2024年03月03日, 編集履歴

Keyknockr(旧CLCL)v2.0をリリース

macOS用ランチャアプリケーション「CLCL」を全面的に作り直し、名称を「Keyknockr」へと変更しました(バージョン番号は継続)。

Keyknockr(旧CLCL)はCommandキーやShiftキーなどの修飾キーを連打することで、設定したアプリケーションやURLを開くことができるランチャアプリケーションです。

今回のバージョンアップにともない、従来のLite版・有料版販売モデルから、フリーミアムモデル(基本無料+サブスクリプションによる機能解放)へと移行し、配信場所はMac App Storeへ統一されました。

v2.0ではイチからSwift/SwiftUIを用いて再構築され、新しい特殊アクションとして、

が追加されました。

「実行中のアプリケーション」アクションは現在実行中の(Dockに表示される)アプリケーションの一覧メニューがポップアップされ、そこから当該アプリケーションをアクティブ化できます。

「ポップアップメニュー」アクションは事前に設定したアプリケーションやローカルファイル、URLの一覧メニューがポップアップされ、そこから起動、アクティブ化、開く操作が実行できます。

また、「ログイン時に起動」設定を追加したので、Keyknockrの設定画面からかんたんに起動設定を変更できるようになりました。

どうぞよろしく。

以前にCLCLをGumroadで購入されていた方へはKeyknockr v2.0機能解放版を提供しています。Gumroadのシステムを通してメールを送信しているので、ご確認ください。

2024年02月20日, 編集履歴

SwiftUIのmacOSアプリでTableにコンテキストメニューをつける

Tableの基本的な使い方は「SwiftUI Tableの使い方」を参照。

macOSアプリにSwiftUIのTableを組み込み、副ボタンクリック(右クリック)によるコンテキストメニューをつけた時、テーブル行として表現されているオブジェクトに対して何らかの操作を行うような場合を考える。たとえばリスト表示にしたFinderで、ファイルやフォルダを右クリックしたときにでるコンテキストメニューをイメージしてもらうと良い。

このときTableColumn配下の要素やTableRow.contextMenu(menuItems:)モディファイアをつけてしまいがちだが、macOSアプリのテーブルに対するコンテキストメニューの自然な挙動を実現するには、Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつけなければならない。

テーブルの選択状態と操作対象

テーブル行をふつうにクリックした場合、行選択の状態となり、行全体にハイライトがつく。行を右クリックした場合、行の縁だけハイライトがつき、コンテキストメニューの起点、操作対象の状態になる。このふたつは別の状態である。選択範囲とコンテキストメニューの操作対象は重なる場合もあれば、異なる場合もある。

コンテキストメニューの操作対象は以下のようになる:

上図のコンテキストメニューの内容から操作対象が変わっていることが解る。

実装

以下のような実装が良いと思う。

import SwiftUI
import UniformTypeIdentifiers

struct Bookmark: Identifiable {

  var id = UUID()
  var title: String
  var url: URL
}

struct ContentView: View {

  @State private var sampleData: [Bookmark] = [
    .init(title: "Genji App", url: URL(string: "https://genjiapp.com")!),
    .init(title: "Apple", url: URL(string: "https://www.apple.com")!),
    .init(title: "Google", url: URL(string: "https://www.google.com")!)
  ]
  @State private var selectedIDs = Set<Bookmark.ID>()

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedIDs) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
//          .contextMenu {
//            Button("ここじゃない") {
//              print(bookmark)
//              print(selectedIDs)
//            }
//          }
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
//          .contextMenu {
//            Button("ここでもない") {
//              print(bookmark)
//              print(selectedIDs)
//            }
//          }
      }
    }
    .tableStyle(.bordered)
    // ここ!
    .contextMenu(forSelectionType: Bookmark.ID.self) { clickedRowIDs in
      addButton
      Button("Delete Bookmark") {
        sampleData.removeAll { clickedRowIDs.contains($0.id) }
      }
    }
    // ここはテーブル内余白を右クリックしたとき
    .contextMenu {
      addButton
    }
    .padding()
  }

  var addButton: some View {
    Button("Add Bookmark") {
      sampleData.append(.init(title: "Microsoft", url: URL(string: "https://www.microsoft.com")!))
    }
  }

}

TableColumn配下の要素に.contextMenu(menuItems:)モディファイアをつける場合、複数の列があるときはすべてにモディファイアをつけなければならないし、右クリックに反応するのは行の中の文字がある部分だけになる。

TableRow.contextMenu(menuItems:)モディファイアをつける場合、選択行とコンテキストメニューの操作対象両方を取得できるが、両者が別の型になるし、両者の重なり具合を調べて操作対象を自分で計算する必要がある。

Table自体に.contextMenu(forSelectionType:menu:primaryAction:)モディファイアをつける場合、第2引数menuクロージャの引数として、標準的な操作対象となるオブジェクトのidSetで取得できるので、その後の処理がストレートに記述できる。

第1引数forSelectionTypeには、テーブル行を表現するオブジェクトを特定するための型を指定する。オブジェクトはIdentifiableプロトコルに適合させているはずなので、idプロパティの型となる。第3引数primaryActionは、行をダブルクリックした時に実行される。

また、ドキュメントには.contextMenu(forSelectionType:menu:primaryAction:)の第2引数menuクロージャの引数が空のSetの場合はテーブルの余白部分を右クリックしたことになるとあるが、macOS 14.3.1とXcode Version 15.2 (15C500b)の環境ではコンテキストメニューが出ない。上のコード例ではTable自体にもうひとつ別の.contextMenu(menuItems:)をつけることで、余白部分を右クリックした場合をカバーしている。

2024年02月18日, 編集履歴

macOS App Sandbox下におけるSecurity-scoped Bookmarkについて

App Sandbox環境下のmacOSアプリにおいて、ローカルファイルへのアクセスには制限が課せられている。

コード内で適当に生成したURLではローカルファイルへアクセスできない。ファイルへのアクセス権を得るには、

等の限られた方法を取る必要がある。

ただしNSOpenPanel等で取得したURLは、その起動中はURL先のファイルにアクセス可能だが、そのURLを保存しておいたとしても、次の起動時にはアクセスできなくなってしまう。URLへのアクセスを永続化するには、Security-scopedなURL Bookmarkを作らなければならない。

URLからSecurity-scoped Bookmarkの生成

URLbookmarkData(options:includingResourceValuesForKeys:relativeTo:)関数を使ってSecurity-scoped Bookmark(Data型)を作成する。options引数にはwithSecurityScope を指定。URLへのアクセスが読み込みのみで良い場合は、securityScopeAllowOnlyReadAccess も併用する。

let bookmarkData = try? url.bookmarkData(options: [.withSecurityScope, .securityScopeAllowOnlyReadAccess], includingResourceValuesForKeys: nil, relativeTo: nil)

Security-scoped BookmarkからURLを生成し、アクセスする

URLinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)関数を使うことで、Security-scoped BookmarkなDataからURLを生成できる。resolvingBookmarkData引数にはSecurity-scoped BookmarkなDataを、options引数にはwithSecurityScopeを指定する。

生成されたURLに実際にアクセスするには、アクセス前にstartAccessingSecurityScopedResource()を実行し、アクセス後にはstopAccessingSecurityScopedResource()を実行する。

var isStale = false
let urlData = // 前項の手法で保存したURLのData
if let url = try? URL(resolvingBookmarkData: urlData, options: .withSecurityScope, relativeTo: nil, bookmarkDataIsStale: &isStale) {
  if url.startAccessingSecurityScopedResource() {

    // URL先への読み書き処理

    url.stopAccessingSecurityScopedResource()
  }
}

URLinit(resolvingBookmarkData:options:relativeTo:bookmarkDataIsStale:)実行後にisStaletrueになっている場合、Bookmark Data作成以降にURL先ファイルの名前が変更されたり、場所が変更されたことを意味する。その場合でも返却されたURLは変更後のものになっており、アクセスは可能だが、新しくSecurity-scoped Bookmarkを生成し直す必要がある。

Security-scoped Bookmarkを再生成するときも、URL先へのアクセスに相当するので、startAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()が必要になる。

2024年02月17日, 編集履歴

SwiftUI Tableの使い方

以下のような構造体の配列をSwiftUIのTableビューに表示する場合を考える。

struct Bookmark {
  var title: String
  var url: URL
}

単純に表形式で表示する

データを単純にTableに表示したいだけの場合、Tableinit<Data>(Data, columns: () -> Columns)と、TableColumninit(Text, content: (RowValue) -> Content)あたりを使う。このとき、表示するデータ構造はIdentifiableプロトコルに適合させる必要があるので、プロパティにidを追加した。

import SwiftUI

struct Bookmark: Identifiable {

  var id = UUID()
  var title: String
  var url: URL

}

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]

  var body: some View {
    Table(sampleData) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

行を選択可能にする

テーブル行を選択可能にする場合、

あたりを使う。selection引数の型がIDのオプショナルか、IDSet型かの違い。

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(sampleData, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }
}

行クリック時に選択可能になった。

ドラッグ&ドロップで並べ替え

テーブル行をドラッグ&ドロップで並べ替え可能にするには、init(of: Value.Type, selection: Binding<Set<Value.ID>>, columns: () -> Columns, rows: () -> Rows)あたりを使う。先ほどまでのイニシャライザとは使い方がちょっと違う。第一引数に表示するデータの型、最後のrows引数にはTableRowで表示データを供給する。以下のような感じ。

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

ここまでは、まだデータの表示と行選択ができるだけで、前項までとは別のイニシャライザを使ったに過ぎない。行をドラッグ&ドロップできるようにするには、draggabledropDestinationモディファイアを使う。この時、表示データはTransferableプロトコルに適合させる必要があり、そのためにはCodableプロトコルにも適合させておくとかんたんになる。また、ドラッグ&ドロップさせるデータのためのカスタムUTTypeも用意する。以下のような感じ。

import SwiftUI
import UniformTypeIdentifiers

extension UTType {
  static let bookmark = UTType(exportedAs: "com.genjiapp.TableSample.bookmark")
}

struct Bookmark: Identifiable, Codable, Transferable {

  static var transferRepresentation: some TransferRepresentation {
    CodableRepresentation(contentType: .bookmark)
  }

  var id = UUID()
  var title: String
  var url: URL

}

struct ContentView: View {

  @State var sampleData: [Bookmark] = [
    Bookmark(title: "Genji App", url: URL(string: "https://genjiapp.com/")!),
    Bookmark(title: "Apple", url: URL(string: "https://www.apple.com/")!),
    Bookmark(title: "Google", url: URL(string: "https://www.google.com/")!)
  ]
  @State private var selectedID: Bookmark.ID?

  var body: some View {
    Table(of: Bookmark.self, selection: $selectedID) {
      TableColumn("Title") { bookmark in
        Text(bookmark.title)
      }
      TableColumn("URL") { bookmark in
        Text(bookmark.url.absoluteString)
      }
    } rows: {
      ForEach(sampleData) { bookmark in
        TableRow(bookmark)
          .draggable(bookmark)
      }
      .dropDestination(for: Bookmark.self) { insertionIndex, insertionBookmarks in
        if let bookmark = insertionBookmarks.first,
           let originalIndex = sampleData.firstIndex(where: { $0.id == bookmark.id }) {
          sampleData.insert(bookmark, at: insertionIndex)
          let removeIndex = (insertionIndex > originalIndex) ? originalIndex : originalIndex + 1
          sampleData.remove(at: removeIndex)
        }
      }
    }
    .tableStyle(.bordered)
    .padding()
  }

}

draggableTableRowに、dropDestinationTableRowを囲むForEachに対して指定する。

また、用意したカスタムUTTypeは、その識別子をInfo.plist内で宣言しておかなければならない。