Skip to content
Open
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
25 changes: 25 additions & 0 deletions ScreenTextGrab/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ final class AppState: ObservableObject {
@Published var speechState: SpeechPlaybackState
@Published var ocrLanguageSelection: OCRLanguageSelection
@Published var captureOutputPreset: CaptureOutputPreset
@Published var monospaceLayoutSettings: MonospaceLayoutSettings
@Published var interfaceLanguage: InterfaceLanguage
@Published var watchConfiguration: WatchConfiguration
@Published var appProfiles: [AppCaptureProfile]
Expand Down Expand Up @@ -76,6 +77,9 @@ final class AppState: ObservableObject {
self.captureOutputPreset = persistsUserPreferences
? CaptureOutputPresetStore.load(defaults: defaults)
: .smart
self.monospaceLayoutSettings = persistsUserPreferences
? MonospaceLayoutSettingsStore.load(defaults: defaults)
: .defaultValue
self.interfaceLanguage = persistsUserPreferences
? InterfaceLanguageStore.load(defaults: defaults)
: .system
Expand Down Expand Up @@ -249,6 +253,22 @@ final class AppState: ObservableObject {
persistCaptureOutputPresetIfNeeded()
}

func setMonospaceLayoutEnabled(_ enabled: Bool) {
monospaceLayoutSettings = MonospaceLayoutSettings(
isEnabled: enabled,
columns: monospaceLayoutSettings.columns
)
persistMonospaceLayoutSettingsIfNeeded()
}

func setMonospaceLayoutColumns(_ columns: Int) {
monospaceLayoutSettings = MonospaceLayoutSettings(
isEnabled: monospaceLayoutSettings.isEnabled,
columns: columns
)
persistMonospaceLayoutSettingsIfNeeded()
}

func setInterfaceLanguage(_ language: InterfaceLanguage) {
interfaceLanguage = language
persistInterfaceLanguageIfNeeded()
Expand Down Expand Up @@ -647,6 +667,11 @@ final class AppState: ObservableObject {
CaptureOutputPresetStore.save(captureOutputPreset, defaults: defaults)
}

private func persistMonospaceLayoutSettingsIfNeeded() {
guard persistsUserPreferences else { return }
MonospaceLayoutSettingsStore.save(monospaceLayoutSettings, defaults: defaults)
}

private func persistInterfaceLanguageIfNeeded() {
guard persistsUserPreferences else { return }
InterfaceLanguageStore.save(interfaceLanguage, defaults: defaults)
Expand Down
44 changes: 44 additions & 0 deletions ScreenTextGrab/Models/AppModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,50 @@ enum CaptureOutputPresetStore {
}
}

struct MonospaceLayoutSettings: Codable, Equatable, Sendable {
var isEnabled: Bool
var columns: Int

static let defaultColumns = 120
static let defaultValue = MonospaceLayoutSettings(isEnabled: false, columns: defaultColumns)

init(isEnabled: Bool = false, columns: Int = defaultColumns) {
self.isEnabled = isEnabled
self.columns = max(20, min(300, columns))
}

private enum CodingKeys: String, CodingKey {
case isEnabled
case columns
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? false
let columns = try container.decodeIfPresent(Int.self, forKey: .columns) ?? Self.defaultColumns
self.init(isEnabled: isEnabled, columns: columns)
}
}

enum MonospaceLayoutSettingsStore {
static let key = "screenTextGrab.monospaceLayoutSettings"

static func load(defaults: UserDefaults = .standard) -> MonospaceLayoutSettings {
guard let data = defaults.data(forKey: key),
let settings = try? JSONDecoder().decode(MonospaceLayoutSettings.self, from: data) else {
return .defaultValue
}
return settings
}

static func save(_ settings: MonospaceLayoutSettings, defaults: UserDefaults = .standard) {
guard let data = try? JSONEncoder().encode(settings) else {
return
}
defaults.set(data, forKey: key)
}
}

struct ClipboardHistoryEntry: Identifiable, Equatable, Sendable, Codable {
enum ContentKind: String, CaseIterable, Identifiable, Codable, Sendable {
case text
Expand Down
85 changes: 85 additions & 0 deletions ScreenTextGrab/Models/OCRResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,91 @@ struct OCRResult: Sendable {
}
}

/// Eş aralıklı (monospace) hizalı metin üretir.
/// Metin bloklarının boundingBox konumlarını kullanarak satırlara ayırır,
/// yatay konumu sabit sütun sayısına göre padding ile korur,
/// dikey boşlukları boş satırlarla ifade eder.
/// - Parameter columns: Çıktı toplam sütun sayısı (varsayılan 120).
/// Yatay hizalama doğruluğunu kontrol eder.
func monospaceAlignedText(columns: Int = 120) -> String {
guard !blocks.isEmpty else {
return ""
}

let resolvedColumns = max(20, min(300, columns))

// y'ye göre sırala (büyük y = ekranın üstü, y yakınsa x'e göre)
let sorted = blocks.sorted { a, b in
if abs(a.boundingBox.midY - b.boundingBox.midY) < 0.02 {
return a.boundingBox.minX < b.boundingBox.minX
}
return a.boundingBox.midY > b.boundingBox.midY
}

// Satır kümeleme
var rows: [[OCRTextBlock]] = []
var currentRow: [OCRTextBlock] = []
for block in sorted {
if let first = currentRow.first {
if abs(block.boundingBox.midY - first.boundingBox.midY) < 0.02 {
currentRow.append(block)
} else {
rows.append(currentRow)
currentRow = [block]
}
} else {
currentRow = [block]
}
}
if !currentRow.isEmpty {
rows.append(currentRow)
}

// Medyan satır yüksekliği — boş satır hesaplaması için
let rowHeights = rows
.compactMap { $0.first?.boundingBox.height }
.filter { $0 > 0 }
.sorted()
let medianRowHeight = rowHeights.isEmpty ? 0.02 : rowHeights[rowHeights.count / 2]

var output = ""
var lastRowTopY: CGFloat?

for row in rows {
let sortedRow = row.sorted { $0.boundingBox.minX < $1.boundingBox.minX }
let rowTopY = sortedRow.map { $0.boundingBox.maxY }.max() ?? 0

// Dikey boşluk → boş satır
if let ly = lastRowTopY {
let gap = ly - rowTopY
let blankLines = Int(gap / max(medianRowHeight * 1.5, 0.015))
if blankLines > 0 {
output += String(repeating: "\n", count: min(blankLines, 6))
}
}

// Yatay: sütun konumuna padding ile hizala
var line = ""
for block in sortedRow {
let col = Int(block.boundingBox.minX * CGFloat(resolvedColumns))
if col > line.count {
line += String(repeating: " ", count: col - line.count)
} else if !line.isEmpty {
line += " "
}
line += block.text
}
output += line + "\n"
lastRowTopY = rowTopY
}

if output.hasSuffix("\n") {
output.removeLast()
}

return output
}

private var standardFormattedText: String {
groupedLines(lineThreshold: 0.03)
.map { line in
Expand Down
27 changes: 25 additions & 2 deletions ScreenTextGrab/Services/CaptureCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1173,8 +1173,10 @@ final class CaptureCoordinator: CaptureCoordinating {
appState.statusMessage = "Panoya kopyalanıyor..."
STGLog.pipeline.info("State → copying")

let effectiveRawText = resolvedRawText(for: best)

let clipboardResult = outputWriter.copyCapturedText(
rawText: best.text,
rawText: effectiveRawText,
captureMode: captureMode,
contentKind: historyContentKind(for: best),
source: source,
Expand All @@ -1193,6 +1195,17 @@ final class CaptureCoordinator: CaptureCoordinating {
}
}

private func resolvedRawText(for selection: CaptureOCRSelection) -> String {
guard appState.monospaceLayoutSettings.isEnabled else {
return selection.text
}

let monospaceText = selection.ocrResult.monospaceAlignedText(
columns: appState.monospaceLayoutSettings.columns
)
return monospaceText.isEmpty ? selection.text : monospaceText
}

nonisolated
private static func evaluateCandidatesAsync(
ocrService: any OCRProviding,
Expand Down Expand Up @@ -1504,8 +1517,18 @@ final class CaptureCoordinator: CaptureCoordinating {
) async -> Bool {
guard appState.watchState == .active else { return false }

let effectiveRawText: String
if appState.monospaceLayoutSettings.isEnabled {
let monospaceText = selection.ocrResult.monospaceAlignedText(
columns: appState.monospaceLayoutSettings.columns
)
effectiveRawText = monospaceText.isEmpty ? rawText : monospaceText
} else {
effectiveRawText = rawText
}

let clipboardResult = outputWriter.copyCapturedText(
rawText: rawText,
rawText: effectiveRawText,
captureMode: currentWatchSession?.captureMode ?? appState.captureMode,
contentKind: historyContentKind(for: selection),
source: currentWatchSession?.source,
Expand Down
38 changes: 38 additions & 0 deletions ScreenTextGrab/Views/Settings/SettingsTabViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ struct SettingsGeneralTabView: View {
let captureModeFeedback: InlineFeedback?
let outputPresetDetail: String
let outputPresetFeedback: InlineFeedback?
let monospaceLayoutEnabledBinding: Binding<Bool>
let monospaceLayoutColumns: Int
let onMonospaceLayoutColumnsChange: (Int) -> Void
let isRecordingHotkey: Bool
let hotkeyDisplayLabel: String
let hotkeyFeedback: HotkeyFeedback?
Expand Down Expand Up @@ -126,6 +129,41 @@ struct SettingsGeneralTabView: View {
}
}

SettingsSectionCard(
title: L10n.pair("Eş Aralıklı Düzen", "Monospace Layout"),
subtitle: L10n.pair("OCR bloklarının ekran konumını kullanarak metni eş aralıklı hizalar. Terminal ve tablo görünümleri için uygun.", "Aligns text using OCR block positions. Suitable for terminal and table layouts.")
) {
Toggle(isOn: monospaceLayoutEnabledBinding) {
Text(L10n.pair("Eş aralıklı hizalamayı kullan", "Use monospace alignment"))
.font(.system(size: 12.5, weight: .semibold, design: .rounded))
}
.toggleStyle(.switch)
.tint(.accentMint)

if monospaceLayoutEnabledBinding.wrappedValue {
VStack(alignment: .leading, spacing: 8) {
Text(L10n.pair("Sütun sayısı: \(monospaceLayoutColumns)", "Columns: \(monospaceLayoutColumns)"))
.font(.system(size: 11.5, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)

Slider(
value: Binding(
get: { Double(monospaceLayoutColumns) },
set: { onMonospaceLayoutColumnsChange(Int($0.rounded())) }
),
in: 40...200,
step: 10
)
.tint(.accentMint)

Text(L10n.pair("Daha fazla sütun = daha hassas yatay hizalama.", "More columns = more precise horizontal alignment."))
.font(.system(size: 10.5, weight: .medium, design: .rounded))
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
}

SettingsSectionCard(
title: L10n.pair("Global Kısayol", "Global Shortcut"),
subtitle: isRecordingHotkey
Expand Down
6 changes: 6 additions & 0 deletions ScreenTextGrab/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ struct SettingsView: View {
captureModeFeedback: captureModeFeedback,
outputPresetDetail: appState.captureOutputPreset.detail,
outputPresetFeedback: outputPresetFeedback,
monospaceLayoutEnabledBinding: Binding(
get: { appState.monospaceLayoutSettings.isEnabled },
set: { appState.setMonospaceLayoutEnabled($0) }
),
monospaceLayoutColumns: appState.monospaceLayoutSettings.columns,
onMonospaceLayoutColumnsChange: { appState.setMonospaceLayoutColumns($0) },
isRecordingHotkey: isRecordingHotkey,
hotkeyDisplayLabel: appState.hotkeyDisplayLabel,
hotkeyFeedback: hotkeyFeedback,
Expand Down
Loading