diff --git a/Sources/MrEditorCore/AppSettings.swift b/Sources/MrEditorCore/AppSettings.swift index aef7c7b..fa98235 100644 --- a/Sources/MrEditorCore/AppSettings.swift +++ b/Sources/MrEditorCore/AppSettings.swift @@ -164,22 +164,29 @@ enum AppSettings { } static var saveProgressStyle: SaveProgressStyle { - get { SaveProgressStyle(rawValue: defaults.string(forKey: saveProgressKey) ?? "") ?? .sheet } + get { saveProgressStyle(from: defaults) } set { defaults.set(newValue.rawValue, forKey: saveProgressKey) } } + private static func saveProgressStyle(from source: UserDefaults) -> SaveProgressStyle { + SaveProgressStyle(rawValue: source.string(forKey: saveProgressKey) ?? "") ?? .sheet + } /// 長い行を折り返すか。false=折り返さず横スクロール(既定)、true=内容幅で折り返す。 static var lineWrap: Bool { - get { defaults.bool(forKey: lineWrapKey) } + get { lineWrap(from: defaults) } set { defaults.set(newValue, forKey: lineWrapKey); NotificationCenter.default.post(name: .mrEditorLineWrapChanged, object: nil) } } + private static func lineWrap(from source: UserDefaults) -> Bool { source.bool(forKey: lineWrapKey) } /// 一致行だけ表示のとき、前後に足す行数(`grep -C` 相当)。既定 0。 /// ファイルごとではなくアプリの設定として覚える(一度決めたら次のファイルでも同じ見え方になる)。 static var filterContextLines: Int { - get { min(max(0, defaults.integer(forKey: filterContextKey)), FilterContext.maxContext) } + get { filterContextLines(from: defaults) } set { defaults.set(min(max(0, newValue), FilterContext.maxContext), forKey: filterContextKey) } } + private static func filterContextLines(from source: UserDefaults) -> Int { + min(max(0, source.integer(forKey: filterContextKey)), FilterContext.maxContext) + } /// ⌘F を「一致行だけ表示」で開くか。**漏斗を最後に自分で操作した状態**を覚える。 /// @@ -188,46 +195,63 @@ enum AppSettings { /// (構造化・JSON)へ移ると強制的に off になり、戻ってきても off のままだった。 /// 覚えるのは**意図**で、使えるかどうかはペインが決める。既定 false(従来どおり)。 static var searchFilterOn: Bool { - get { defaults.bool(forKey: searchFilterKey) } + get { searchFilterOn(from: defaults) } set { defaults.set(newValue, forKey: searchFilterKey) } } + private static func searchFilterOn(from source: UserDefaults) -> Bool { source.bool(forKey: searchFilterKey) } /// タブの表示幅(文字数)。既定 4。選択肢は 2/4/8。 static var tabWidth: Int { - get { let v = defaults.integer(forKey: tabWidthKey); return v > 0 ? v : 4 } + get { tabWidth(from: defaults) } set { defaults.set(newValue, forKey: tabWidthKey); postDisplayChanged() } } + private static func tabWidth(from source: UserDefaults) -> Int { + let v = source.integer(forKey: tabWidthKey); return v > 0 ? v : 4 + } /// 行間。 static var lineSpacing: LineSpacing { - get { LineSpacing(rawValue: defaults.string(forKey: lineSpacingKey) ?? "") ?? .standard } + get { lineSpacing(from: defaults) } set { defaults.set(newValue.rawValue, forKey: lineSpacingKey); postDisplayChanged() } } + private static func lineSpacing(from source: UserDefaults) -> LineSpacing { + LineSpacing(rawValue: source.string(forKey: lineSpacingKey) ?? "") ?? .standard + } /// キャレット行を淡い帯で強調するか。既定 true。 static var highlightCurrentLine: Bool { - get { defaults.object(forKey: highlightCurrentLineKey) as? Bool ?? true } + get { highlightCurrentLine(from: defaults) } set { defaults.set(newValue, forKey: highlightCurrentLineKey); postDisplayChanged() } } + private static func highlightCurrentLine(from source: UserDefaults) -> Bool { + source.object(forKey: highlightCurrentLineKey) as? Bool ?? true + } /// 行番号(ガター)を表示するか。既定 true。 /// 巨大ファイル側は自前ガター、小ファイル側は NSRulerView が担うが、設定は 1 つ。 static var showLineNumbers: Bool { - get { defaults.object(forKey: showLineNumbersKey) as? Bool ?? true } + get { showLineNumbers(from: defaults) } set { defaults.set(newValue, forKey: showLineNumbersKey); postDisplayChanged() } } + private static func showLineNumbers(from source: UserDefaults) -> Bool { + source.object(forKey: showLineNumbersKey) as? Bool ?? true + } /// 不可視文字(タブ・改行・全角スペース・行末の半角スペース)を記号で見せるか。既定 false。 static var showInvisibles: Bool { - get { defaults.bool(forKey: showInvisiblesKey) } + get { showInvisibles(from: defaults) } set { defaults.set(newValue, forKey: showInvisiblesKey); postDisplayChanged() } } + private static func showInvisibles(from source: UserDefaults) -> Bool { source.bool(forKey: showInvisiblesKey) } /// キャレット形状。 static var cursorShape: CursorShape { - get { CursorShape(rawValue: defaults.string(forKey: cursorShapeKey) ?? "") ?? .bar } + get { cursorShape(from: defaults) } set { defaults.set(newValue.rawValue, forKey: cursorShapeKey); postDisplayChanged() } } + private static func cursorShape(from source: UserDefaults) -> CursorShape { + CursorShape(rawValue: source.string(forKey: cursorShapeKey) ?? "") ?? .bar + } /// 前回終了時のセッション(左サイドバーの並び順・アクティブ位置)。次回起動時に復元する。 static var session: SessionState? { @@ -314,9 +338,12 @@ enum AppSettings { /// **オフでも変更は知らせる**(本文下端のバナー)。黙って古い内容を見せ続けることはしない。 /// 未保存の変更があるときは、オンでも自動では取り込まない(潰してしまうため)。 static var autoReloadExternalChanges: Bool { - get { defaults.object(forKey: autoReloadKey) as? Bool ?? true } + get { autoReloadExternalChanges(from: defaults) } set { defaults.set(newValue, forKey: autoReloadKey) } } + private static func autoReloadExternalChanges(from source: UserDefaults) -> Bool { + source.object(forKey: autoReloadKey) as? Bool ?? true + } /// 起動時に新しい版が出ていないか自動で調べるか。既定 true。 /// App Store 配布ではないため、これが無いと利用者は新版に気づけない。 @@ -392,6 +419,40 @@ enum AppSettings { private static func postDisplayChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観を除いた挙動設定の値だけの束。 + struct BehaviorSnapshot { + var saveProgressStyle: SaveProgressStyle + var lineWrap: Bool + var filterContextLines: Int + var searchFilterOn: Bool + var tabWidth: Int + var lineSpacing: LineSpacing + var highlightCurrentLine: Bool + var showLineNumbers: Bool + var showInvisibles: Bool + var cursorShape: CursorShape + var autoReloadExternalChanges: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。**書き込みはしない。** + /// セッション・AI 設定・更新チェックはここに含まれない([[FreeSettingsImport]] のコメント参照)。 + static func behaviorSnapshot(from source: UserDefaults) -> BehaviorSnapshot { + BehaviorSnapshot( + saveProgressStyle: saveProgressStyle(from: source), + lineWrap: lineWrap(from: source), + filterContextLines: filterContextLines(from: source), + searchFilterOn: searchFilterOn(from: source), + tabWidth: tabWidth(from: source), + lineSpacing: lineSpacing(from: source), + highlightCurrentLine: highlightCurrentLine(from: source), + showLineNumbers: showLineNumbers(from: source), + showInvisibles: showInvisibles(from: source), + cursorShape: cursorShape(from: source), + autoReloadExternalChanges: autoReloadExternalChanges(from: source)) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/Core/FreeSettingsImport.swift b/Sources/MrEditorCore/Core/FreeSettingsImport.swift new file mode 100644 index 0000000..45fabf8 --- /dev/null +++ b/Sources/MrEditorCore/Core/FreeSettingsImport.swift @@ -0,0 +1,106 @@ +import AppKit + +/// 同じ Mac にインストールされた無料版(MrEditor)から、Pro 側の設定へ安全に取り込む。 +/// +/// **Pro 専用の機能。** 呼び出し口は Pro 側(`MrkPro`)にしか存在しない。core 自身のメニュー・ +/// 実行ファイルはこの型を一切参照しないので、無料版に機能が漏れることはない +/// (`scripts/check_consistency.py` の pro_gate 検査は「core 内で `ProFeature` の +/// ケースを未ゲートで使っていないか」を見るものだが、この型はそもそも `ProFeature` の +/// ケースではないので対象外 —— ゲートの必要が無い形にしてある)。 +/// +/// 意図的に含めないもの: +/// - セッション・下書き([[AppSettings.session]])… 開いていたファイルの復元情報 +/// - ファイルごとの列定義(`MrEditor.columnFields`)… 絶対パスをキーに持つため +/// - 自動更新チェックの設定・最終チェック時刻 … アプリごとに更新経路が別 +/// - AI プロバイダ設定 … 既に Keychain 共有アカウントで解決済み([[AppSettings.aiConfig]])。 +/// ここでも UserDefaults 経由で運ぶと、真実の源が二重になる +/// - 秘密情報(API キー等) … そもそも UserDefaults には無く Keychain にしかない +public struct FreeSettingsImport: Equatable { + // 外観([[EditorTheme]] / [[EditorFont]]) + public var themePresetRawValue: String + /// `EditorTheme.ColorKey.rawValue` → 色。 + public var customColors: [String: NSColor] + public var backgroundOpacity: Double + public var ansiColors: Bool + public var fontName: String? + public var fontSize: Double + + // 挙動([[AppSettings]]) + public var saveProgressStyleRawValue: String + public var lineWrap: Bool + public var filterContextLines: Int + public var searchFilterOn: Bool + public var tabWidth: Int + public var lineSpacingRawValue: String + public var highlightCurrentLine: Bool + public var showLineNumbers: Bool + public var showInvisibles: Bool + public var cursorShapeRawValue: String + public var autoReloadExternalChanges: Bool + + /// 指定したバンドル ID の UserDefaults ドメインから読む。 + /// そのアプリが一度も起動されていない等でドメインが空なら nil。 + public static func capture(fromBundleID bundleID: String) -> FreeSettingsImport? { + guard let source = UserDefaults(suiteName: bundleID), + let domain = source.persistentDomain(forName: bundleID), !domain.isEmpty else { return nil } + + let theme = EditorTheme.appearanceSnapshot(from: source) + let font = EditorFont.snapshot(from: source) + let behavior = AppSettings.behaviorSnapshot(from: source) + + return FreeSettingsImport( + themePresetRawValue: theme.preset.rawValue, + customColors: theme.customColors, + backgroundOpacity: Double(theme.backgroundOpacity), + ansiColors: theme.ansiColorsEnabled, + fontName: font.name, + fontSize: Double(font.size), + saveProgressStyleRawValue: behavior.saveProgressStyle.rawValue, + lineWrap: behavior.lineWrap, + filterContextLines: behavior.filterContextLines, + searchFilterOn: behavior.searchFilterOn, + tabWidth: behavior.tabWidth, + lineSpacingRawValue: behavior.lineSpacing.rawValue, + highlightCurrentLine: behavior.highlightCurrentLine, + showLineNumbers: behavior.showLineNumbers, + showInvisibles: behavior.showInvisibles, + cursorShapeRawValue: behavior.cursorShape.rawValue, + autoReloadExternalChanges: behavior.autoReloadExternalChanges) + } + + /// 自分自身(呼び出し側アプリ)のグローバル設定へ適用する。 + /// 既存の setter 経由なので、開いているウィンドウへの通知も既存の仕組みでそのまま飛ぶ。 + public func apply() { + // custom 色を先に入れると preset が一旦 .custom に倒れるため、preset は最後に確定する + // ([[SettingsBundle.apply]] と同じ順序)。 + for key in EditorTheme.ColorKey.allCases { + if let color = customColors[key.rawValue] { + EditorTheme.setCustomColor(key, color) + } + } + EditorTheme.preset = ThemePreset(rawValue: themePresetRawValue) ?? .system + EditorTheme.backgroundOpacity = CGFloat(backgroundOpacity) + EditorTheme.ansiColorsEnabled = ansiColors + EditorFont.setName(fontName) + EditorFont.setSize(CGFloat(fontSize)) + + AppSettings.saveProgressStyle = SaveProgressStyle(rawValue: saveProgressStyleRawValue) ?? .sheet + AppSettings.lineWrap = lineWrap + AppSettings.filterContextLines = filterContextLines + AppSettings.searchFilterOn = searchFilterOn + AppSettings.tabWidth = tabWidth + AppSettings.lineSpacing = LineSpacing(rawValue: lineSpacingRawValue) ?? .standard + AppSettings.highlightCurrentLine = highlightCurrentLine + AppSettings.showLineNumbers = showLineNumbers + AppSettings.showInvisibles = showInvisibles + AppSettings.cursorShape = CursorShape(rawValue: cursorShapeRawValue) ?? .bar + AppSettings.autoReloadExternalChanges = autoReloadExternalChanges + } + + /// 確認シート用の短い説明([[SettingsBundle.summaryLines]] と同じ見せ方)。 + public func summaryLines() -> [String] { + let themeName = L("prefs.theme.\(themePresetRawValue)") + let fontDesc = (fontName ?? L("prefs.font.system")) + " \(Int(fontSize)) pt" + return ["\(L("prefs.theme")): \(themeName)", "\(L("prefs.font")): \(fontDesc)"] + } +} diff --git a/Sources/MrEditorCore/EditorFont.swift b/Sources/MrEditorCore/EditorFont.swift index 7d07e7f..b88c697 100644 --- a/Sources/MrEditorCore/EditorFont.swift +++ b/Sources/MrEditorCore/EditorFont.swift @@ -62,6 +62,14 @@ enum EditorFont { } return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。書き込みはしない。 + static func snapshot(from source: UserDefaults) -> (name: String?, size: CGFloat) { + let v = source.double(forKey: sizeKey) + return (source.string(forKey: nameKey), v > 0 ? CGFloat(v) : defaultSize) + } } extension Notification.Name { diff --git a/Sources/MrEditorCore/EditorTheme.swift b/Sources/MrEditorCore/EditorTheme.swift index 9c3914a..6e621b6 100644 --- a/Sources/MrEditorCore/EditorTheme.swift +++ b/Sources/MrEditorCore/EditorTheme.swift @@ -68,22 +68,26 @@ enum EditorTheme { // MARK: - プリセット選択 static var preset: ThemePreset { - get { ThemePreset(rawValue: defaults.string(forKey: presetKey) ?? "") ?? .system } + get { preset(from: defaults) } set { defaults.set(newValue.rawValue, forKey: presetKey); postChanged() } } + private static func preset(from source: UserDefaults) -> ThemePreset { + ThemePreset(rawValue: source.string(forKey: presetKey) ?? "") ?? .system + } // MARK: - 背景の不透明度(ウィンドウ全体・iTerm 風) /// 本文+周辺 UI の背景を透かす度合い。1.0=完全不透明(既定・従来と同一)、 /// 0.30 まで下げられる。透明時は窓を非不透明にして背後のデスクトップを見せる。 static var backgroundOpacity: CGFloat { - get { - guard defaults.object(forKey: opacityKey) != nil else { return 1.0 } - let v = CGFloat(defaults.double(forKey: opacityKey)) - return min(1.0, max(0.30, v)) - } + get { backgroundOpacity(from: defaults) } set { defaults.set(Double(min(1.0, max(0.30, newValue))), forKey: opacityKey); postChanged() } } + private static func backgroundOpacity(from source: UserDefaults) -> CGFloat { + guard source.object(forKey: opacityKey) != nil else { return 1.0 } + let v = CGFloat(source.double(forKey: opacityKey)) + return min(1.0, max(0.30, v)) + } /// 背景が完全不透明か(不透明度 1.0)。透明プラミングの有効化判定に使う。 static var isOpaqueBackground: Bool { backgroundOpacity >= 0.999 } @@ -101,9 +105,12 @@ enum EditorTheme { /// ログ中の ANSI SGR エスケープ(`ESC[…m`)を色に変換して表示するか。 /// 既定 ON(生のエスケープ列は可読でないため)。閲覧経路でのみ適用する。 static var ansiColorsEnabled: Bool { - get { defaults.object(forKey: ansiKey) == nil ? true : defaults.bool(forKey: ansiKey) } + get { ansiColorsEnabled(from: defaults) } set { defaults.set(newValue, forKey: ansiKey); postChanged() } } + private static func ansiColorsEnabled(from source: UserDefaults) -> Bool { + source.object(forKey: ansiKey) == nil ? true : source.bool(forKey: ansiKey) + } // MARK: - 現在の配色 @@ -182,8 +189,10 @@ enum EditorTheme { } /// custom の 1 色を読む。未設定なら system プリセットの対応色にフォールバック。 - static func customColor(_ key: ColorKey) -> NSColor { - if let data = defaults.data(forKey: customPrefix + key.rawValue), + static func customColor(_ key: ColorKey) -> NSColor { customColor(key, from: defaults) } + + private static func customColor(_ key: ColorKey, from source: UserDefaults) -> NSColor { + if let data = source.data(forKey: customPrefix + key.rawValue), let color = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NSColor.self, from: data) { return color } @@ -249,4 +258,29 @@ enum EditorTheme { private static func postChanged() { NotificationCenter.default.post(name: .mrEditorDisplayChanged, object: nil) } + + // MARK: - 他ドメインからの読み取り([[FreeSettingsImport]] 専用) + + /// [[FreeSettingsImport]] が運ぶ、外観設定の値だけの束。書き込みはしない。 + struct AppearanceSnapshot { + var preset: ThemePreset + /// `ColorKey.rawValue` → 色。custom プリセットでなくても常に埋めて返す + /// (後から custom に切り替えても迷わないように)。 + var customColors: [String: NSColor] + var backgroundOpacity: CGFloat + var ansiColorsEnabled: Bool + } + + /// 任意の UserDefaults ドメイン(他アプリの設定)から読む。 + static func appearanceSnapshot(from source: UserDefaults) -> AppearanceSnapshot { + var colors: [String: NSColor] = [:] + for key in ColorKey.allCases { + colors[key.rawValue] = customColor(key, from: source) + } + return AppearanceSnapshot( + preset: preset(from: source), + customColors: colors, + backgroundOpacity: backgroundOpacity(from: source), + ansiColorsEnabled: ansiColorsEnabled(from: source)) + } }