Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 72 additions & 11 deletions Sources/MrEditorCore/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 を「一致行だけ表示」で開くか。**漏斗を最後に自分で操作した状態**を覚える。
///
Expand All @@ -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? {
Expand Down Expand Up @@ -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 配布ではないため、これが無いと利用者は新版に気づけない。
Expand Down Expand Up @@ -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 {
Expand Down
106 changes: 106 additions & 0 deletions Sources/MrEditorCore/Core/FreeSettingsImport.swift
Original file line number Diff line number Diff line change
@@ -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)"]
}
}
8 changes: 8 additions & 0 deletions Sources/MrEditorCore/EditorFont.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
52 changes: 43 additions & 9 deletions Sources/MrEditorCore/EditorTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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: - 現在の配色

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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))
}
}
Loading