diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fd83025..692f758 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,6 +2,8 @@ name: Build on: push: + branches: + - main pull_request: permissions: diff --git a/README.md b/README.md index 8c306da..d4b68ca 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ * Q: How do I have it startup with my Mac? - A: Go to System Settings -> General -> [add NiceStats to the "Open at Login" section](https://files.catbox.moe/ycsntg.jpg). * Q: What versions are supported? - - A: Theoretically, it should work on macOS 14.0 and higher but I have not tested it on anything older than 27.0.1 yet. + - A: Theoretically, it should work on macOS 14.0 and higher but the oldest version that was tested one of the latest versions of Sequoia. - Q: Intel Mac eta wen? - A: I don't have the time to test functionality on Intel Macs especially considering that they're already deprecated. PRs for Intel Macs are welcome however. If someone can confirm whether the tool works on Intel Macs or not, I will update the GitHub Action to build an Intel version. - Q: Can I contribute? - A: Of course! Contributions are more than welcome. Do note that AI usage is restricted here, refer to the CONTRIBUTING.md file. - Q: Was AI involved in this? - - A: Yes. I have used an LLM (Sonnet 5.5) for the battery icon and GitHub Actions code. Everything else was coded by me with some review and debugging by said LLM. + - A: Yes. I have used an LLM (Sonnet 5.5) for the battery icon, GitHub Actions code, and working on some optimizations. Everything else was coded by me with some review and debugging by said LLM. ## Credits A good portion of the source code was referenced from [Stats](https://github.com/exelban/stats) (for SMC logic and etc) and [Ampere](https://github.com/MTACS/Ampere) (for the battery icon). @@ -24,7 +24,8 @@ Huge thanks to the following people for helping me with translating the app: | Language | Translator | | --- | --- | | German, Russian | [ZomkaDEV](https://github.com/ZomkaDEV/) | -| Spanish (Latin American) | [etonedemid](https://github.com/etonedemid/) | +| Spanish (Latin American) | [etonedemid](https://github.com/etonedemid/), [Snekolotl](https://guns.lol/snekolotl) | | Korean | [Helloyunho](https://github.com/Helloyunho) | -| French | MrLeRien | +| French | MrLeRien, [Maelstrom](https://maelstrom.dev) | | Italian | [chic\_luke](https://t.me/chic_luke) | +| Portuguese | [ChrisCoy](https://github.com/ChrisCoy) | diff --git a/src/Drive.swift b/src/Drive.swift new file mode 100644 index 0000000..a9dcd6c --- /dev/null +++ b/src/Drive.swift @@ -0,0 +1,97 @@ +import Foundation +import IOKit + +struct DriveInfo { + var solidState: Bool? + var nvme = false + var interconnect: String? + var external: Bool? + var health: Int? + var warning = false + var written: Double? + + var typeText: String? { + let parts = [solidState.map { $0 ? "SSD" : "HDD" }, nvme ? "NVMe" : nil].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + var connectionText: String? { + let parts = [external.map { $0 ? String(localized: "External") : String(localized: "Internal") }, interconnect].compactMap { $0 } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } +} + +func driveBytes(_ v: Double) -> String { ByteCountFormatter.string(fromByteCount: Int64(v), countStyle: .decimal) } + +private func className(_ e: io_object_t) -> String { + var b = [CChar](repeating: 0, count: 128) + IOObjectGetClass(e, &b) + return String(cString: b) +} + +func readDrive(path: String) -> DriveInfo? { + var fs = statfs() + guard statfs(path, &fs) == 0 else { return nil } + let from = withUnsafePointer(to: &fs.f_mntfromname) { $0.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) } } + var cur = IOServiceGetMatchingService(kIOMainPortDefault, IOBSDNameMatching(kIOMainPortDefault, 0, (from as NSString).lastPathComponent)) + var device: io_registry_entry_t = 0 + for _ in 0..<16 where cur != 0 { + if IOObjectConformsTo(cur, "IOBlockStorageDevice") != 0 { device = cur; break } + var parent: io_registry_entry_t = 0 + IORegistryEntryGetParentEntry(cur, kIOServicePlane, &parent) + IOObjectRelease(cur); cur = parent + } + guard device != 0 else { if cur != 0 { IOObjectRelease(cur) }; return nil } + defer { IOObjectRelease(device) } + + func prop(_ key: String, _ sub: String) -> Any? { + (IORegistryEntryCreateCFProperty(device, key as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? [String: Any])?[sub] + } + var info = DriveInfo() + info.nvme = className(device).contains("NVMe") + switch prop("Device Characteristics", "Medium Type") as? String { + case "Solid State": info.solidState = true + case "Rotational": info.solidState = false + default: break + } + info.interconnect = prop("Protocol Characteristics", "Physical Interconnect") as? String + switch prop("Protocol Characteristics", "Physical Interconnect Location") as? String { + case "Internal": info.external = false + case "External": info.external = true + default: break + } + if info.nvme, let s = readNVMeSMART(device) { + info.health = max(0, 100 - s.percentUsed) + info.warning = s.critical != 0 + info.written = s.unitsWritten * 512_000 + } + return info +} + +private struct SmartVtbl { + var reserved: UnsafeMutableRawPointer? + var queryInterface: UnsafeMutableRawPointer? + var addRef: UnsafeMutableRawPointer? + var release: (@convention(c) (UnsafeMutableRawPointer?) -> UInt32)? + var version: UInt16 + var revision: UInt16 + var smartReadData: (@convention(c) (UnsafeMutableRawPointer?, UnsafeMutableRawPointer?) -> IOReturn)? +} + +private func readNVMeSMART(_ service: io_service_t) -> (percentUsed: Int, critical: UInt8, unitsWritten: Double)? { + let typeID = CFUUIDGetConstantUUIDWithBytes(nil, 0xAA, 0x0F, 0xA6, 0xF9, 0xC2, 0xD6, 0x45, 0x7F, 0xB1, 0x0B, 0x59, 0xA1, 0x32, 0x53, 0x29, 0x2F) + let ifaceID = CFUUIDGetConstantUUIDWithBytes(nil, 0xCC, 0xD1, 0xDB, 0x19, 0xFD, 0x9A, 0x4D, 0xAF, 0xBF, 0x95, 0x12, 0x45, 0x4B, 0x23, 0x0A, 0xB6) + let plugID = CFUUIDGetConstantUUIDWithBytes(nil, 0xC2, 0x44, 0xE8, 0x58, 0x10, 0x9C, 0x11, 0xD4, 0x91, 0xD4, 0x00, 0x50, 0xE4, 0xC6, 0x42, 0x6F) + var plugin: UnsafeMutablePointer?>? + var score: Int32 = 0 + guard IOCreatePlugInInterfaceForService(service, typeID, plugID, &plugin, &score) == KERN_SUCCESS, let plugin else { return nil } + defer { IODestroyPlugInInterface(plugin) } + var out: LPVOID? + guard plugin.pointee!.pointee.QueryInterface(plugin, CFUUIDGetUUIDBytes(ifaceID), &out) == 0, let out else { return nil } + let iface = out.assumingMemoryBound(to: UnsafeMutablePointer.self) + defer { _ = iface.pointee.pointee.release?(iface) } + var buf = [UInt8](repeating: 0, count: 512) + let rc = buf.withUnsafeMutableBytes { iface.pointee.pointee.smartReadData?(iface, $0.baseAddress) ?? -1 } + guard rc == 0 else { return nil } + let written = (0..<8).reduce(0.0) { $0 + Double(buf[48 + $1]) * pow(256, Double($1)) } + return (Int(buf[5]), buf[0], written) +} diff --git a/src/Localizable.xcstrings b/src/Localizable.xcstrings index 8c9b6b3..119b25c 100644 --- a/src/Localizable.xcstrings +++ b/src/Localizable.xcstrings @@ -69,6 +69,54 @@ "value" : "%@ · 코어 %lld개" } }, + "pt-BR" : { + "variations" : { + "plural" : { + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleos" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleo" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleos" + } + } + } + } + }, + "pt-PT" : { + "variations" : { + "plural" : { + "many" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleos" + } + }, + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleo" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · %lld núcleos" + } + } + } + } + }, "ru" : { "variations" : { "plural" : { @@ -133,6 +181,18 @@ "value" : "%@ 남음" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ restantes" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ restantes" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -173,6 +233,18 @@ "value" : "충전 완료까지 %@" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ até carga completa" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ até à carga completa" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -213,6 +285,18 @@ "value" : "30초 전" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "há 30 s" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "há 30 s" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -221,1362 +305,3064 @@ } } }, - "Battery" : { + "Add another drive" : { + "comment" : "A button that adds a new drive to the list of drives to monitor.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Batterie" + "value" : "Weiteres Laufwerk hinzufügen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Batería" + "value" : "Juntar otra unidad" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Batterie" + "value" : "Ajouter un autre disque" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Batteria" + "value" : "다른 드라이브 추가" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "배터리" + "value" : "Adicionar outra unidade" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar outra unidade" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Аккумулятор" + "value" : "Добавить ещё один диск" } } } }, - "Charged capacity" : { + "Add separator" : { + "comment" : "A button that adds a separator to the list of stats.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Geladene Kapazität" + "value" : "Trennlinie hinzufügen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Capacidad máxima" + "value" : "Juntar separador" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Capacité maximale" + "value" : "Ajouter un séparateur" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Carica massima" + "value" : "구분선 추가" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "최대 용량" + "value" : "Adicionar separador" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar separador" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Полная ёмкость" + "value" : "Добавить разделитель" } } } }, - "Charging (%@)" : { + "Add separator below" : { + "comment" : "A button that adds a separator below a tile in the overview.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Lädt (%@)" + "value" : "Trennlinie darunter hinzufügen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Cargando (%@)" + "value" : "Juntar separador debajo" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "En charge (%@)" + "value" : "Ajouter un séparateur en dessous" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "In carica (%@)" + "value" : "아래에 구분선 추가" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "충전 중(%@)" + "value" : "Adicionar separador abaixo" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar separador abaixo" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Зарядка (%@)" + "value" : "Добавить разделитель ниже" } } } }, - "Connection" : { + "Bar" : { + "comment" : "Display name for a stat style that uses a horizontal bar.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Verbindung" + "value" : "Balken" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Conexión" + "value" : "Barra" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Connexion" + "value" : "Barre" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Connessione" + "value" : "막대" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "연결" + "value" : "Barra" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barra" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подключение" + "value" : "Полоса" } } } }, - "CPU" : { + "Battery" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "CPU" + "value" : "Batterie" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "CPU" + "value" : "Batería" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "CPU" + "value" : "Batterie" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "CPU" + "value" : "Batteria" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "CPU" + "value" : "배터리" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bateria" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ЦП" + "value" : "Аккумулятор" } } } }, - "Critical" : { + "Cancel" : { + "comment" : "Button title for the cancel action in an alert.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Kritisch" + "value" : "Abbrechen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Crítica" + "value" : "Cancelar" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Critique" + "value" : "Annuler" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Critico" + "value" : "취소" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "위험" + "value" : "Cancelar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancelar" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Критическая" + "value" : "Отмена" } } } }, - "Current capacity" : { + "Charged capacity" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Aktuelle Kapazität" + "value" : "Geladene Kapazität" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Capacidad actual" + "value" : "Capacidad máxima" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Capacité actuelle" + "value" : "Capacité maximale" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Capacità attuale" + "value" : "Carica massima" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "현재 용량" + "value" : "최대 용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade máxima" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade máxima" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Текущая ёмкость" + "value" : "Полная ёмкость" } } } }, - "Cycles" : { + "Charging (%@)" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Zyklen" + "value" : "Lädt (%@)" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ciclos" + "value" : "Cargando (%@)" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Cycles" + "value" : "En charge (%@)" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Cicli" + "value" : "In carica (%@)" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사이클" + "value" : "충전 중(%@)" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando (%@)" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "A carregar (%@)" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Циклы" + "value" : "Зарядка (%@)" } } } }, - "Designed capacity" : { + "Connection" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Nennkapazität" + "value" : "Verbindung" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Capacidad de diseño" + "value" : "Conexión" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Capacité d'origine" + "value" : "Connexion" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Capacità di fabbrica" + "value" : "Connessione" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "설계 용량" + "value" : "연결" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conexão" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligação" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ëмкость с завода" + "value" : "Подключение" } } } }, - "Display" : { + "CPU" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Display" + "value" : "CPU" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Pantalla" + "value" : "CPU" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Écran" + "value" : "CPU" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Display" + "value" : "CPU" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "디스플레이" + "value" : "CPU" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "CPU" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Дисплей" + "value" : "ЦП" } } } }, - "Download" : { + "Critical" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Download" + "value" : "Kritisch" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Descarga" + "value" : "Crítica" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Réception" + "value" : "Critique" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Download" + "value" : "Critico" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다운로드" + "value" : "위험" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crítica" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crítica" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Приём" + "value" : "Критическая" } } } }, - "Ethernet" : { + "Current capacity" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ethernet" + "value" : "Aktuelle Kapazität" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Ethernet" + "value" : "Capacidad actual" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Ethernet" + "value" : "Capacité actuelle" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Ethernet" + "value" : "Capacità attuale" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이더넷" + "value" : "현재 용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade atual" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade atual" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Ethernet" + "value" : "Текущая ёмкость" } } } }, - "Free" : { + "Cycles" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Frei" + "value" : "Zyklen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Libre" + "value" : "Ciclos" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Libre" + "value" : "Cycles" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Libera" + "value" : "Cicli" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용 가능" + "value" : "사이클" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclos" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ciclos" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Свободно" + "value" : "Циклы" } } } }, - "GPU" : { + "Data written" : { + "comment" : "Label for the amount of data written by a drive.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "GPU" + "value" : "Geschriebene Daten" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "GPU" + "value" : "Datos escritos" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "GPU" + "value" : "Données écrites" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "GPU" + "value" : "총 기록량" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "GPU" + "value" : "Dados gravados" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dados escritos" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "ГП" + "value" : "Записано данных" } } } }, - "Local IP" : { + "Designed capacity" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Lokale IP" + "value" : "Nennkapazität" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "IP local" + "value" : "Capacidad de diseño" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "IP locale" + "value" : "Capacité d'origine" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "IP locale" + "value" : "Capacità di fabbrica" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로컬 IP" + "value" : "설계 용량" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade de projeto" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Capacidade de conceção" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Локальный IP" + "value" : "Ëмкость с завода" } } } }, - "Memory" : { + "Display" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Speicher" + "value" : "Display" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria" + "value" : "Pantalla" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mémoire" + "value" : "Écran" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria" + "value" : "Display" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "메모리" + "value" : "디스플레이" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tela" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ecrã" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Память" + "value" : "Дисплей" } } } }, - "Memory allocated" : { + "Done" : { + "comment" : "A button that dismisses a view when pressed.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Zugewiesener Speicher" + "value" : "Fertig" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria asignada" + "value" : "Listo" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mémoire allouée" + "value" : "Terminer" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria allocata" + "value" : "완료" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "할당된 메모리" + "value" : "Concluído" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Concluído" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Выделенная память" + "value" : "Готово" } } } }, - "Memory in use" : { + "Download" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Belegter Speicher" + "value" : "Download" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria en uso" + "value" : "Descarga" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Mémoire utilisée" + "value" : "Réception" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Memoria in uso" + "value" : "Download" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용 중인 메모리" + "value" : "다운로드" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Используемая память" + "value" : "Приём" } } } }, - "Model" : { + "Drag to rearrange. Right-click a tile for options." : { + "comment" : "A footer displayed in the overview view, explaining how to rearrange and hide tiles.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Modell" + "value" : "Zum Umsortieren ziehen. Rechtsklick auf eine Kachel für Optionen." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Modelo" + "value" : "Arrastra para reordenar. Haz clic derecho en un mosaico para ver las opciones." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Modèle" + "value" : "Faites glisser pour réorganiser. Faites un clic droit sur une tuile pour afficher les options." } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Modello" + "value" : "드래그하여 순서를 바꾸세요. 타일을 마우스 오른쪽 버튼으로 클릭하면 옵션이 표시됩니다." } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "모델" + "value" : "Arraste para reorganizar. Clique com o botão direito em um bloco para ver as opções." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Arraste para reorganizar. Clique com o botão direito num bloco para ver as opções." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Модель" + "value" : "Перетаскивайте, чтобы изменить порядок. Нажмите правой кнопкой на плитку для настроек." } } } }, - "N/A" : { + "Drive" : { + "comment" : "A label for selecting a drive in the storage overview.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "k. A." + "value" : "Laufwerk" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "N/D" + "value" : "Unidad" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "N/D" + "value" : "Disque" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "N/D" + "value" : "드라이브" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "해당 없음" + "value" : "Unidade" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unidade" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Н/Д" + "value" : "Диск" } } } }, - "Network" : { + "Edit layout" : { + "comment" : "A button that opens a window for editing the layout of the stats.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Netzwerk" + "value" : "Layout bearbeiten" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Red" + "value" : "Editar diseño" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Réseau" + "value" : "Modifier la disposition" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Rete" + "value" : "레이아웃 편집" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "네트워크" + "value" : "Editar layout" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Editar disposição" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Сеть" + "value" : "Изменить расположение" } } } }, - "Normal" : { + "Ethernet" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Normal" + "value" : "Ethernet" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Normal" + "value" : "Ethernet" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Normal" + "value" : "Ethernet" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Normale" + "value" : "Ethernet" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "보통" + "value" : "이더넷" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ethernet" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ethernet" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Норма" + "value" : "Ethernet" } } } }, - "Not connected" : { + "External" : { + "comment" : "Text describing how a drive is connected to the computer.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Nicht verbunden" + "value" : "Extern" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Sin conexión" + "value" : "Externa" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Non connecté" + "value" : "Externe" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Non connesso" + "value" : "외장" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "연결되지 않음" + "value" : "Externa" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Externa" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Нет подключения" + "value" : "Внешний" } } } }, - "now" : { + "Free" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "jetzt" + "value" : "Frei" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "ahora" + "value" : "Libre" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "maintenant" + "value" : "Libre" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "ora" + "value" : "Libera" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "지금" + "value" : "사용 가능" } }, - "ru" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "сейчас" + "value" : "Livre" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Livre" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Свободно" + } + } + } + }, + "GPU" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "GPU" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "ГП" + } + } + } + }, + "Graph" : { + "comment" : "Title of a stat style when the style is graph.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagramm" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gráfica" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Graphique" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그래프" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gráfico" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gráfico" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "График" + } + } + } + }, + "Health" : { + "comment" : "Label for the health status of a drive.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zustand" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Salud" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "État" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "상태" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Saúde" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Состояние" + } + } + } + }, + "Hidden" : { + "comment" : "A heading for the hidden tiles in the settings view.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausgeblendet" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escondido" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masqués" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨김" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultos" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultos" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрытые" + } + } + } + }, + "Hide" : { + "comment" : "A button that hides a tile.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ausblenden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escondir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Masquer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "숨기기" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ocultar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Скрыть" + } + } + } + }, + "Internal" : { + "comment" : "Text describing how a drive is connected to the computer.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intern" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interna" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interne" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "내장" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interna" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Interna" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Внутренний" + } + } + } + }, + "Local IP" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokale IP" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP local" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP locale" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP locale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "로컬 IP" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP local" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "IP local" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Локальный IP" + } + } + } + }, + "Memory" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speicher" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mémoire" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "메모리" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Память" + } + } + } + }, + "Memory allocated" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zugewiesener Speicher" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria asignada" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mémoire allouée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria allocata" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "할당된 메모리" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória alocada" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória alocada" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Выделенная память" + } + } + } + }, + "Memory in use" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belegter Speicher" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria en uso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mémoire utilisée" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memoria in uso" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용 중인 메모리" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória em uso" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Memória em utilização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Используемая память" + } + } + } + }, + "Model" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modell" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modèle" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modello" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모델" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Modelo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Модель" + } + } + } + }, + "N/A" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "k. A." + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "N/D" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "N/D" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "N/D" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "해당 없음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "N/D" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "N/D" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Н/Д" + } + } + } + }, + "Needs attention" : { + "comment" : "Text describing a drive that needs attention.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prüfung erforderlich" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requiere atención" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requis d'attention" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "확인 필요" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requer atenção" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Requer atenção" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требует внимания" + } + } + } + }, + "Network" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Netzwerk" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Red" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réseau" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rete" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "네트워크" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rede" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rede" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сеть" + } + } + } + }, + "Normal" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normale" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보통" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Normal" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Норма" + } + } + } + }, + "Not connected" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht verbunden" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin conexión" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non connecté" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Non connesso" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결되지 않음" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Não conectado" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sem ligação" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Нет подключения" + } + } + } + }, + "now" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "jetzt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "ahora" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "maintenant" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "ora" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지금" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "agora" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "agora" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "сейчас" + } + } + } + }, + "On battery" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akkubetrieb" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Con batería" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sur batterie" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "A batteria" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "배터리 사용 중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Na bateria" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Com bateria" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "От аккумулятора" + } + } + } + }, + "Other" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonstiges" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Otros" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Autres" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Altro" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기타" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outros" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Outros" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Прочее" + } + } + } + }, + "Pin panel" : { + "comment" : "Label for a menu item that toggles whether the panel should remain open.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panel fixieren" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fijar panel" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Épingler le panneau" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "패널 고정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixar painel" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fixar painel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Закрепить панель" + } + } + } + }, + "Plugged in" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angeschlossen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Branché" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collegato" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원 연결됨" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligado à corrente" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключен к сети" + } + } + } + }, + "Plugged in · Discharging" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angeschlossen · Entlädt" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado · Descargando" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Branché · Décharge" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collegato · In scarica" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원 연결됨 · 방전 중" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado · Descarregando" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ligado à corrente · A descarregar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Подключен к сети · Разряжается" + } + } + } + }, + "Power" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Leistung" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energía" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alimentation" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alimentazione" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "전원" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energia" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Energia" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Питание" } } } }, - "On battery" : { + "Pressure" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Akkubetrieb" + "value" : "Speicherdruck" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Con batería" + "value" : "Presión" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Sur batterie" + "value" : "Pression" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "A batteria" + "value" : "Pressione" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "배터리 사용 중" + "value" : "메모리 압력" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pressão" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pressão" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "От аккумулятора" + "value" : "Нагрузка" } } } }, - "Other" : { + "Quit" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Sonstiges" + "value" : "Beenden" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Otros" + "value" : "Salir" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Autres" + "value" : "Quitter" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Altro" + "value" : "Esci" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기타" + "value" : "종료" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sair" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sair" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Прочее" + "value" : "Выйти" } } } }, - "Plugged in" : { + "Remove" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Angeschlossen" + "value" : "Entfernen" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Conectado" + "value" : "Quitar" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Branché" + "value" : "Retirer" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "제거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить" + } + } + } + }, + "Remove separator" : { + "comment" : "A button that removes a separator from the layout.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trennlinie entfernen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quitar separador" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Retirer le séparateur" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "구분선 제거" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover separador" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remover separador" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Удалить разделитель" + } + } + } + }, + "Render" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendering" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renderizado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rendu" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Collegato" + "value" : "Render" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "렌더러" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renderização" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Renderização" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Рендер" + } + } + } + }, + "Reset" : { + "comment" : "Button title for resetting the layout.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repor" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить" + } + } + } + }, + "Reset layout" : { + "comment" : "A button that resets the layout to its default state.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Layout zurücksetzen" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer diseño" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser la disposition" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "레이아웃 재설정" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redefinir layout" } }, - "ko" : { + "pt-PT" : { "stringUnit" : { "state" : "translated", - "value" : "전원 연결됨" + "value" : "Repor disposição" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подключен к сети" + "value" : "Сбросить расположение" } } } }, - "Plugged in · Discharging" : { + "Reset layout?" : { + "comment" : "Alert message when the user wants to reset the layout.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Angeschlossen · Entlädt" + "value" : "Layout zurücksetzen?" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Conectado · Descargando" + "value" : "¿Restablecer el diseño?" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Branché · Décharge" + "value" : "Réinitialiser la disposition ?" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Collegato · In scarica" + "value" : "레이아웃을 재설정할까요?" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "전원 연결됨 · 방전 중" + "value" : "Redefinir o layout?" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Repor a disposição?" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подключен к сети · Разряжается" + "value" : "Сбросить расположение?" } } } }, - "Power" : { + "Ring" : { + "comment" : "Display name for the ring style of a stat.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Leistung" + "value" : "Ring" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Energía" + "value" : "Anillo" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Alimentation" + "value" : "Anneau" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Alimentazione" + "value" : "원형 링" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "전원" + "value" : "Circular" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Circular" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Питание" + "value" : "Кольцо" } } } }, - "Pressure" : { + "Storage" : { + "comment" : "A label for the storage section of the detail view.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Speicherdruck" + "value" : "Festplatte" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Presión" + "value" : "Disco" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Pression" + "value" : "Stockage" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Pressione" + "value" : "저장 공간" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "메모리 압력" + "value" : "Disco" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Disco" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Нагрузка" + "value" : "Диск" } } } }, - "Quit" : { + "Style" : { + "comment" : "A label for the style of a tile.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beenden" + "value" : "Stil" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Salir" + "value" : "Estilo" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Quitter" + "value" : "Style" } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Esci" + "value" : "스타일" } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "종료" + "value" : "Estilo" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estilo" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Выйти" + "value" : "Стиль" } } } }, - "Render" : { + "Swap" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Rendering" + "value" : "Auslagerung" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Renderizado" + "value" : "Swap" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Rendu" + "value" : "Swap" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Render" + "value" : "Swap" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "렌더러" + "value" : "스왑" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swap" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swap" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Рендер" + "value" : "Подкачка" } } } }, - "Swap" : { + "Temperature" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Auslagerung" + "value" : "Temperatur" } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Swap" + "value" : "Temperatura" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Swap" + "value" : "Température" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Swap" + "value" : "Temperatura" } }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "스왑" + "value" : "온도" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Temperatura" } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Подкачка" + "value" : "Температура" } } } }, - "Temperature" : { + "This puts every stat back to its default style and position." : { + "comment" : "Alert message when the user wants to reset the layout.", + "isCommentAutoGenerated" : true, "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Temperatur" + "value" : "Alle Werte werden auf Standardstil und -position zurückgesetzt." } }, "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Temperatura" + "value" : "Todos los elementos volverán a su estilo y posición predeterminados." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Température" + "value" : "Tous les éléments reprendront leur style et leur position par défaut." } }, - "it" : { + "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Temperatura" + "value" : "모든 항목의 스타일과 위치가 기본값으로 돌아갑니다." } }, - "ko" : { + "pt-BR" : { "stringUnit" : { "state" : "translated", - "value" : "온도" + "value" : "Todos os itens voltarão ao estilo e à posição padrão." + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Todos os itens voltarão ao estilo e à posição predefinidos." } }, "ru" : { "stringUnit" : { "state" : "translated", - "value" : "Температура" + "value" : "Все показатели вернутся к стилю и положению по умолчанию." } } } @@ -1613,6 +3399,18 @@ "value" : "Thunderbolt 브리지" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thunderbolt Bridge" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thunderbolt Bridge" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1653,6 +3451,18 @@ "value" : "타일러" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiler" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tiler" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1693,6 +3503,18 @@ "value" : "남은 시간" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo restante" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo restante" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1733,6 +3555,18 @@ "value" : "충전 완료까지" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo para carregar" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tempo para carregar" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1773,6 +3607,18 @@ "value" : "전체" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Total" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1781,6 +3627,54 @@ } } }, + "Type" : { + "comment" : "Label for the type of a drive.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Typ" + } + }, + "es-419" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "유형" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tipo" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Тип" + } + } + } + }, "Upload" : { "localizations" : { "de" : { @@ -1813,6 +3707,18 @@ "value" : "업로드" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upload" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Upload" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1853,6 +3759,18 @@ "value" : "사용량" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Uso" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilização" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1893,6 +3811,18 @@ "value" : "사용됨" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usado" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Utilizado" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1933,6 +3863,18 @@ "value" : "전압" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voltagem" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tensão" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -1973,6 +3915,18 @@ "value" : "VPN" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "VPN" + } + }, "ru" : { "stringUnit" : { "state" : "translated", @@ -2013,6 +3967,18 @@ "value" : "경고" } }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" + } + }, + "pt-PT" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aviso" + } + }, "ru" : { "stringUnit" : { "state" : "translated", diff --git a/src/NiceStats.swift b/src/NiceStats.swift index a585a59..615c836 100644 --- a/src/NiceStats.swift +++ b/src/NiceStats.swift @@ -15,6 +15,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var host: NSHostingView! private var outside: Any? private var page: Page? + private let state = PanelState() private var navigating = false private var scrollMonitor: Any? private var swipe = 0.0 @@ -28,18 +29,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { item.button?.action = #selector(click) item.button?.sendAction(on: [.leftMouseUp, .rightMouseUp]) - host = NSHostingView(rootView: root()) let blur = NSVisualEffectView() blur.material = .menu; blur.blendingMode = .behindWindow; blur.state = .active blur.maskImage = Self.roundedMask(radius: 20) - host.autoresizingMask = [.minYMargin] - blur.addSubview(host) panel.contentView = blur panel.isOpaque = false; panel.backgroundColor = .clear; panel.hasShadow = true panel.level = .statusBar; panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] item.button?.imagePosition = .imageOnly sampler.onTick = { [weak self] in self?.refreshIcon() } + state.onEditing = { [weak self] in self?.sampler.paused = $0 } refreshIcon() } @@ -76,7 +75,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { host.frame = NSRect(x: 0, y: panel.contentView!.bounds.height - s.height, width: s.width, height: s.height) } - private func root() -> PanelRoot { PanelRoot(sampler: sampler, page: page, onSelect: { [weak self] in self?.navigate($0) }, onSize: { [weak self] in self?.contentResized($0) }) } + private func root() -> PanelRoot { PanelRoot(sampler: sampler, state: state, page: page, onSelect: { [weak self] in self?.navigate($0) }, onSize: { [weak self] in self?.contentResized($0) }) } private static func roundedMask(radius r: CGFloat) -> NSImage { let img = NSImage(size: NSSize(width: r * 2 + 1, height: r * 2 + 1), flipped: false) { rect in @@ -97,11 +96,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func click() { if NSApp.currentEvent?.type == .rightMouseUp { let m = NSMenu() + m.addItem(withTitle: String(localized: "Edit layout"), action: #selector(startEditing), keyEquivalent: "").target = self + m.addItem(withTitle: String(localized: "Reset layout"), action: #selector(resetLayout), keyEquivalent: "").target = self + let pin = NSMenuItem(title: String(localized: "Pin panel"), action: #selector(togglePin), keyEquivalent: "") + pin.target = self; pin.state = state.pinned ? .on : .off + m.addItem(pin) + m.addItem(.separator()) m.addItem(withTitle: String(localized: "Quit"), action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") item.menu = m; item.button?.performClick(nil); item.menu = nil } else if panel.isVisible { hide() } else { show() } } + @objc private func resetLayout() { + let a = NSAlert() + a.messageText = String(localized: "Reset layout?") + a.informativeText = String(localized: "This puts every stat back to its default style and position.") + a.addButton(withTitle: String(localized: "Reset")) + a.addButton(withTitle: String(localized: "Cancel")) + a.buttons[0].hasDestructiveAction = true + NSApp.activate() + if a.runModal() == .alertFirstButtonReturn { UserDefaults.standard.removeObject(forKey: StatLayout.key) } + } + + @objc private func togglePin() { state.pinned.toggle() } + + @objc private func startEditing() { + if panel.isVisible { navigate(nil) } else { show() } + state.editing = true + } + private func frame(for size: CGSize) -> NSRect { guard let w = item.button?.window else { return .zero } let r = w.frame, screen = w.screen?.visibleFrame ?? .zero @@ -112,24 +135,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func show() { cancelAnimation() sampler.visible = true; sampler.refreshPanel() - page = nil; host.rootView = root() + page = nil + host = NSHostingView(rootView: root()) + host.autoresizingMask = [.minYMargin] + panel.contentView?.addSubview(host) panel.setFrame(frame(for: host.fittingSize), display: false) fit() host.alphaValue = 1 panel.makeKeyAndOrderFront(nil) scrollMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] e in self?.scroll(e); return e } outside = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in + if self?.state.pinned == true || self?.state.editing == true { return } if let w = self?.item.button?.window, w.frame.contains(NSEvent.mouseLocation) { return } self?.hide() } } private func hide() { + state.editing = false if let o = outside { NSEvent.removeMonitor(o); outside = nil } if let m = scrollMonitor { NSEvent.removeMonitor(m); scrollMonitor = nil } cancelAnimation() sampler.visible = false panel.orderOut(nil) + host?.removeFromSuperview(); host = nil } private func cancelAnimation() { diff --git a/src/Readers.swift b/src/Readers.swift index 1b5c459..c8beb98 100644 --- a/src/Readers.swift +++ b/src/Readers.swift @@ -29,9 +29,13 @@ struct Snapshot { var cpuTemp = 0.0, gpuTemp = 0.0 var ramUsed = 0.0, ramTotal = 1.0, swap = 0.0, pressure = String(localized: "Normal") var up = 0.0, down = 0.0, localIP = notAvailable + var volumes: [Volume] = [] + var drives: [String: DriveInfo] = [:] var link = notAvailable var upHist: [Double] = [], downHist: [Double] = [] var ramPct: Double { ramUsed / ramTotal } + /// nil path is the startup volume. + func volume(_ path: String?) -> Volume? { volumes.first { $0.path == (path ?? "/") } } } private func sysctlInt(_ name: String) -> Int { @@ -110,6 +114,24 @@ func readRAM() -> (used: Double, total: Double, swap: Double, pressure: String) pr >= 4 ? String(localized: "Critical") : pr >= 2 ? String(localized: "Warning") : String(localized: "Normal")) } +struct Volume: Identifiable { + var path: String, name: String + var used: Double, total: Double + var id: String { path } + var fraction: Double { total > 0 ? used / total : 0 } +} + +func readVolumes() -> [Volume] { + let keys: [URLResourceKey] = [.volumeNameKey, .volumeTotalCapacityKey, .volumeAvailableCapacityKey, .volumeAvailableCapacityForImportantUsageKey, .volumeIsLocalKey] + let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: keys, options: [.skipHiddenVolumes]) ?? [] + let all: [Volume] = urls.compactMap { u in + guard let v = try? u.resourceValues(forKeys: Set(keys)), v.volumeIsLocal == true, let total = v.volumeTotalCapacity, total > 0 else { return nil } + let free = v.volumeAvailableCapacityForImportantUsage.map(Double.init) ?? Double(v.volumeAvailableCapacity ?? 0) + return Volume(path: u.path, name: v.volumeName ?? u.lastPathComponent, used: Double(total) - free, total: Double(total)) + } + return all.filter { $0.path == "/" } + all.filter { $0.path != "/" } +} + struct GPUInfo { var usage = 0.0, render = 0.0, tiler = 0.0 var memUsed = 0.0, memAlloc = 0.0 diff --git a/src/Sampler.swift b/src/Sampler.swift index fe2715a..ffda534 100644 --- a/src/Sampler.swift +++ b/src/Sampler.swift @@ -3,40 +3,93 @@ import Observation @Observable final class Sampler { var s = Snapshot() - @ObservationIgnored var visible = false + @ObservationIgnored var visible = false { + didSet { + guard visible != oldValue else { return } + if visible { opened() } else { schedule() } + } + } + @ObservationIgnored var paused = false @ObservationIgnored var onTick: (() -> Void)? private let cpu = CPUReader(), net = NetReader(), smc = SMC(), energy = EnergyReader(), thermal = ThermalReader() private var slowW: Double? private var fastW = 0.0 + private var lastBattery: Date? + private var timer: Timer? + private var drivesRead: Date? init() { - tick() - let t = Timer(timeInterval: 1, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.tick() } } + sampleBattery() + schedule() + let ctx = Unmanaged.passUnretained(self).toOpaque() + let source = IOPSNotificationCreateRunLoopSource({ ctx in + guard let ctx else { return } + let me = Unmanaged.fromOpaque(ctx).takeUnretainedValue() + MainActor.assumeIsolated { if !me.visible { me.idleTick() } } + }, ctx).takeRetainedValue() + CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode) + } + + private func schedule() { + timer?.invalidate() + let interval = visible ? 1.0 : 30.0 + let t = Timer(timeInterval: interval, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.visible == true ? self?.tick() : self?.idleTick() } + } + t.tolerance = interval * 0.2 RunLoop.main.add(t, forMode: .common) + timer = t + } + + private func opened() { + _ = cpu.read(); _ = net.read() + s.upHist = []; s.downHist = [] + schedule() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in if self?.visible == true { self?.tick() } } + } + + private func idleTick() { + sampleBattery() + onTick?() } private func tick() { + guard !paused else { return } let cores = cpu.read(), n = net.read() s.cores = cores; s.eCores = min(cpu.eCores, cores.count); s.cpu = avg(cores) (s.up, s.down, s.localIP) = n s.upHist = (s.upHist + [n.up]).suffix(30); s.downHist = (s.downHist + [n.down]).suffix(30) + sampleBattery() + refreshPanel() + refreshDrives() + onTick?() + } + private func refreshDrives() { + guard drivesRead.map({ Date().timeIntervalSince($0) > 60 }) ?? true else { return } + drivesRead = Date() + s.drives = Dictionary(s.volumes.compactMap { v in readDrive(path: v.path).map { (v.path, $0) } }) { a, _ in a } + } + + private func sampleBattery() { + let now = Date(), dt = lastBattery.map { now.timeIntervalSince($0) } ?? 1 + lastBattery = now var b = readBattery() if let w = smc.value("PSTR"), w > 0 { b.sysW = w } let draw = PowerState(b).bat if draw <= 0 { slowW = nil } else { - let slow = (slowW ?? draw) + (draw - (slowW ?? draw)) / 300 - fastW = slowW == nil ? draw : fastW + (draw - fastW) / 20 + let slow0 = slowW ?? draw + let slow = slow0 + (draw - slow0) * min(1, dt / 300) + fastW = slowW == nil ? draw : fastW + (draw - fastW) * min(1, dt / 20) slowW = slow b.avgW = slow + 0.5 * max(0, fastW - slow) } s.battery = b - if visible { refreshPanel() } - onTick?() } func refreshPanel() { (s.ramUsed, s.ramTotal, s.swap, s.pressure) = readRAM() + s.volumes = readVolumes() s.gpuInfo = readGPU() s.link = readLink() s.cpuTemp = avg(thermal.read().filter { $0.key.hasPrefix("PMU tdie") }.map(\.value)) diff --git a/src/Views/Components.swift b/src/Views/Components.swift index fbbf4e4..52b64f1 100644 --- a/src/Views/Components.swift +++ b/src/Views/Components.swift @@ -55,6 +55,17 @@ struct Ring: View { } } +struct StatBar: View { + let value: Double + var color: Color = .blue + var body: some View { + Capsule().fill(.quaternary).frame(height: 6) + .overlay(alignment: .leading) { + GeometryReader { g in Capsule().fill(color).frame(width: max(6, g.size.width * min(max(value, 0), 1))) } + } + } +} + struct BatteryShape: View { let percent: Int var body: some View { diff --git a/src/Views/RootView.swift b/src/Views/RootView.swift index 61915e6..e88d72c 100644 --- a/src/Views/RootView.swift +++ b/src/Views/RootView.swift @@ -1,9 +1,23 @@ import SwiftUI -enum Page { - case cpu, gpu, ram, network, battery +enum Page: String, Codable { + case cpu, gpu, ram, storage, network, battery var title: LocalizedStringKey { - switch self { case .cpu: "CPU"; case .gpu: "GPU"; case .ram: "Memory"; case .network: "Network"; case .battery: "Battery" } + switch self { case .cpu: "CPU"; case .gpu: "GPU"; case .ram: "Memory"; case .storage: "Storage"; case .network: "Network"; case .battery: "Battery" } + } +} + +extension Page { + var color: Color { + switch self { case .cpu: .blue; case .gpu: .purple; case .ram: .orange; case .storage: .green; case .network: .red; case .battery: .green } + } + func reading(_ s: Snapshot) -> (Double, String) { + switch self { + case .cpu: (s.cpu, temp(s.cpuTemp)) + case .gpu: (s.gpuInfo.usage, temp(s.gpuTemp)) + case .ram: (s.ramPct, bytes(s.ramUsed)) + default: (0, "") + } } } @@ -30,6 +44,23 @@ struct DetailView: View { } case .ram: Section_ { Row("Used", bytes(s.ramUsed)); Row("Free", bytes(s.ramTotal - s.ramUsed)); Row("Total", bytes(s.ramTotal)); Row("Swap", bytes(s.swap)); Row("Pressure", s.pressure) } + case .storage: + ForEach(Array(s.volumes.enumerated()), id: \.element.id) { i, v in + if i > 0 { Hairline() } + Section_ { + Text(verbatim: v.name).font(.headline) + Row("Used", bytes(v.used)); Row("Free", bytes(v.total - v.used)); Row("Total", bytes(v.total)) + StatBar(value: v.fraction, color: .green).padding(.top, 2) + if let d = s.drives[v.path] { + VStack(alignment: .leading, spacing: 6) { + if let type = d.typeText { Row("Type", type) } + if let c = d.connectionText { Row("Connection", c) } + if let h = d.health { Row("Health", d.warning ? String(localized: "Needs attention") : "\(h) %") } + if let w = d.written { Row("Data written", driveBytes(w)) } + }.padding(.top, 10) + } + } + } case .network: Section_ { Row("Connection", s.link); Row("Upload", bytes(s.up, rate: true)); Row("Download", bytes(s.down, rate: true)); Row("Local IP", s.localIP) } Hairline() @@ -42,7 +73,17 @@ struct DetailView: View { struct OverviewView: View { let sampler: Sampler + let state: PanelState let onSelect: (Page) -> Void + @AppStorage(StatLayout.key) private var layout = StatLayout.default + @State private var dragging: String? + @State private var dragStart = CGRect.zero + @State private var dragRole = Slot.Role.full + @State private var dragOffset = CGSize.zero + @State private var dragGrabX: CGFloat = 0 + @State private var lockedHeight: CGFloat? + @State private var lastTarget: String? + @State private var frames = FrameStore() var body: some View { let s = sampler.s, b = s.battery, p = PowerState(b) @@ -58,42 +99,218 @@ struct OverviewView: View { } Spacer() }.padding(10) - } + }.allowsHitTesting(!state.editing) Hairline().padding(.vertical, 4) - HStack(spacing: 0) { - ring(.cpu, s.cpu, .blue, temp(s.cpuTemp)) - ring(.gpu, s.gpuInfo.usage, .purple, temp(s.gpuTemp)) - ring(.ram, s.ramPct, .orange, bytes(s.ramUsed)) + grid(s) + if state.editing { editFooter } + } + .padding(.vertical, 6).frame(width: 300) + } + + private func grid(_ s: Snapshot) -> some View { + let arranged = layout.arranged() + return TileFlow { + ForEach(arranged, id: \.tile.id) { t, slot in + Group { + if state.editing { editTile(t, slot, s) } else { content(t, slot.role, s) } + }.slot(slot) } - Hairline().padding(.vertical, 4) - MenuButton { onSelect(.network) } label: { - HStack(spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(verbatim: "↑ \(bytes(s.up, rate: true))").foregroundStyle(.red) - Text(verbatim: "↓ \(bytes(s.down, rate: true))").foregroundStyle(.blue) - }.font(.caption).monospacedDigit().frame(width: 100, alignment: .leading) - Sparkline(up: s.upHist, down: s.downHist).frame(height: 40) - }.padding(10) + } + .frame(width: 300).frame(minHeight: lockedHeight, alignment: .top) + .coordinateSpace(name: "grid") + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { frames.height = $0 } + .overlay(alignment: .topLeading) { + if let id = dragging, let t = layout.items.first(where: { $0.id == id }) { + let isRing = t.kind == .ring, w = isRing ? min(dragStart.width, TileFlow.ringW) : dragStart.width + content(t, dragRole, s) + .frame(width: w, height: dragStart.height) + .background(.regularMaterial, in: .rect(cornerRadius: 12)) + .shadow(radius: 8, y: 2) + .offset(x: (isRing ? dragGrabX - w / 2 : dragStart.minX) + dragOffset.width, y: dragStart.minY + dragOffset.height) + .allowsHitTesting(false) } } - .padding(.vertical, 6).frame(width: 300) + .gesture(reorder(arranged), including: state.editing ? .all : .subviews) + .contextMenu { + if state.editing { + Button("Add separator") { layout.items.append(.separator()) } + Button("Reset layout") { layout = .default } + } + } + } + + private func reorder(_ arranged: [(tile: Tile, slot: Slot)]) -> some Gesture { + DragGesture(minimumDistance: 4, coordinateSpace: .named("grid")) + .onChanged { v in + if dragging == nil { + guard let hit = arranged.first(where: { frames.rects[$0.tile.id]?.contains(v.startLocation) == true }), let r = frames.rects[hit.tile.id] else { return } + dragging = hit.tile.id; dragStart = r; dragRole = hit.slot.role; dragGrabX = v.startLocation.x; lastTarget = nil + lockedHeight = frames.height + } + guard let id = dragging else { return } + dragOffset = v.translation + if frames.rects[id]?.contains(v.location) == true { lastTarget = nil; return } + guard let over = arranged.first(where: { $0.tile.id != id && frames.rects[$0.tile.id]?.contains(v.location) == true })?.tile.id, over != lastTarget else { return } + lastTarget = over + withAnimation(.snappy(duration: 0.25)) { layout.move(id, onto: over) } + } + .onEnded { _ in + withAnimation(.snappy(duration: 0.2)) { dragging = nil; dragOffset = .zero; lockedHeight = nil } + lastTarget = nil + } + } + + @ViewBuilder private func content(_ t: Tile, _ role: Slot.Role, _ s: Snapshot) -> some View { + if let p = t.page { + if p == .network { networkGraph(s) } + else if t.style == .ring { ring(t, p, s) } + else if role == .stacked { compactBar(t, p, s) } + else { bar(t, p, s) } + } else { Hairline().padding(.vertical, state.editing ? 14 : 4) } } - private func ring(_ p: Page, _ v: Double, _ c: Color, _ sub: String) -> some View { + private func editTile(_ t: Tile, _ slot: Slot, _ s: Snapshot) -> some View { + let lifted = dragging == t.id + return content(t, slot.role, s).allowsHitTesting(false) + .opacity(lifted ? 0 : 1) + .frame(maxWidth: .infinity) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(lifted ? Color.accentColor : Color.secondary.opacity(0.5), style: .init(lineWidth: lifted ? 2 : 1, dash: lifted ? [] : [4])).padding(3)) + .contentShape(.rect) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named("grid")) } action: { frames.rects[t.id] = $0 } + .contextMenu { menu(for: t) } + } + + private func update(_ id: String, _ change: (inout Tile) -> Void) { + if let i = layout.items.firstIndex(where: { $0.id == id }) { change(&layout.items[i]) } + } + + @ViewBuilder private func menu(for t: Tile) -> some View { + if let page = t.page { + if page.styles.count > 1 { + Picker("Style", selection: Binding(get: { t.style }, set: { s in update(t.id) { $0.style = s } })) { + ForEach(page.styles, id: \.self) { Text($0.title).tag($0) } + } + } + if page == .storage { + let volumes = sampler.s.volumes + Picker("Drive", selection: Binding(get: { t.volume ?? "/" }, set: { v in update(t.id) { $0.volume = v == "/" ? nil : v } })) { + ForEach(volumes) { Text(verbatim: $0.name).tag($0.path) } + } + Button("Add another drive") { + let used = Set(layout.items.filter { $0.page == .storage }.map { $0.volume ?? "/" }) + if let i = layout.items.firstIndex(where: { $0.id == t.id }) { + layout.items.insert(.drive(volumes.first { !used.contains($0.path) }?.path ?? "/"), at: i + 1) + } + } + } + Divider() + if t.isExtraDrive { + Button("Remove") { layout.items.removeAll { $0.id == t.id } } + } else { + Button("Hide") { update(t.id) { $0.enabled = false } } + } + Button("Add separator below") { + if let i = layout.items.firstIndex(where: { $0.id == t.id }) { layout.items.insert(.separator(), at: i + 1) } + } + } else { + Button("Remove separator") { layout.items.removeAll { $0.id == t.id } } + } + } + + @ViewBuilder private var editFooter: some View { + let hidden = layout.items.filter { !$0.enabled && $0.page != nil } + Hairline().padding(.vertical, 4) + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Drag to rearrange. Right-click a tile for options.").font(.caption).foregroundStyle(.secondary) + Spacer() + Button("Done") { state.editing = false } + } + if !hidden.isEmpty { + HStack(spacing: 6) { + Text("Hidden").font(.caption).foregroundStyle(.secondary) + ForEach(hidden) { t in + Button { update(t.id) { $0.enabled = true } } label: { + Text(t.page!.title).font(.caption).padding(.horizontal, 8).padding(.vertical, 3).background(.quaternary, in: .capsule) + }.buttonStyle(.plain) + } + } + } + } + .padding(.horizontal, 16).padding(.top, 4).padding(.bottom, 6).frame(maxWidth: .infinity, alignment: .leading) + } + + private func name(_ t: Tile, _ p: Page, _ s: Snapshot) -> Text { + if p == .storage, let path = t.volume { return Text(verbatim: s.volume(path)?.name ?? URL(fileURLWithPath: path).lastPathComponent) } + return Text(p.title) + } + + private func compactBar(_ t: Tile, _ p: Page, _ s: Snapshot) -> some View { + let (v, sub) = t.reading(s) + return MenuButton { onSelect(p) } label: { + VStack(alignment: .leading, spacing: 5) { + name(t, p, s).font(.callout.weight(.semibold)).lineLimit(1).minimumScaleFactor(0.8) + StatBar(value: v, color: p.color) + Text(verbatim: "\(pct(v)) · \(sub)").font(.caption2).foregroundStyle(.secondary).monospacedDigit().lineLimit(1).minimumScaleFactor(0.7) + }.padding(8).frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func networkGraph(_ s: Snapshot) -> some View { + MenuButton { onSelect(.network) } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(verbatim: "↑ \(bytes(s.up, rate: true))").foregroundStyle(.red) + Text(verbatim: "↓ \(bytes(s.down, rate: true))").foregroundStyle(.blue) + }.font(.caption).monospacedDigit().frame(width: 100, alignment: .leading) + Sparkline(up: s.upHist, down: s.downHist).frame(height: 40) + }.padding(10) + } + } + + @ViewBuilder private func ring(_ t: Tile, _ p: Page, _ s: Snapshot) -> some View { + let (v, sub) = t.reading(s) MenuButton { onSelect(p) } label: { VStack(spacing: 10) { - Ring(value: v, color: c, size: 64) + Ring(value: v, color: p.color, size: 52) VStack(spacing: 3) { - Text(p.title).font(.callout.weight(.semibold)) + name(t, p, s).font(.callout.weight(.semibold)).lineLimit(1).minimumScaleFactor(0.8) Text(sub).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) } }.padding(.vertical, 8).frame(maxWidth: .infinity) } } + + @ViewBuilder private func bar(_ t: Tile, _ p: Page, _ s: Snapshot) -> some View { + let (v, sub) = t.reading(s) + MenuButton { onSelect(p) } label: { + VStack(spacing: 5) { + HStack { + name(t, p, s).font(.callout.weight(.semibold)) + Spacer() + Text(sub).font(.caption).foregroundStyle(.secondary).monospacedDigit() + Text(pct(v)).font(.callout).monospacedDigit().frame(width: 44, alignment: .trailing) + } + StatBar(value: v, color: p.color) + }.padding(.horizontal, 10).padding(.vertical, 7) + } + } +} + +final class FrameStore { + var rects: [String: CGRect] = [:] + var height: CGFloat = 0 +} + +@Observable final class PanelState { + var pinned = false + var editing = false { didSet { onEditing?(editing) } } + @ObservationIgnored var onEditing: ((Bool) -> Void)? } struct PanelRoot: View { let sampler: Sampler + let state: PanelState let page: Page? let onSelect: (Page?) -> Void let onSize: (CGSize) -> Void @@ -109,7 +326,7 @@ struct PanelRoot: View { Hairline() DetailView(sampler: sampler, page: page) } else { - OverviewView(sampler: sampler, onSelect: onSelect) + OverviewView(sampler: sampler, state: state, onSelect: onSelect) } } .frame(width: 300).fixedSize() diff --git a/src/Views/StatLayout.swift b/src/Views/StatLayout.swift new file mode 100644 index 0000000..e5c7f21 --- /dev/null +++ b/src/Views/StatLayout.swift @@ -0,0 +1,186 @@ +import SwiftUI + +enum StatStyle: String, Codable { + case ring, bar, graph + var title: LocalizedStringKey { + switch self { case .ring: "Ring"; case .bar: "Bar"; case .graph: "Graph" } + } +} + +extension Page { + var styles: [StatStyle] { self == .network ? [.graph] : [.ring, .bar] } +} + +struct Tile: Codable, Identifiable { + enum Kind { case ring, bar, wide } + + var page: Page? + var style: StatStyle + var enabled = true + var volume: String? + private var uid: String? + var id: String { uid ?? page?.rawValue ?? "" } + var isExtraDrive: Bool { page == .storage && uid != nil } + + var kind: Kind { page == nil || page == .network ? .wide : style == .ring ? .ring : .bar } + + init(page: Page) { self.page = page; style = page.styles[0] } + static func drive(_ path: String) -> Tile { + var t = Tile(page: .storage) + t.uid = UUID().uuidString; t.volume = path + return t + } + static func separator() -> Tile { + var t = Tile(page: .cpu) + t.page = nil; t.uid = UUID().uuidString; t.style = .bar + return t + } + + init(from d: Decoder) throws { + let c = try d.container(keyedBy: CodingKeys.self) + page = try c.decodeIfPresent(Page.self, forKey: .page) + uid = try c.decodeIfPresent(String.self, forKey: .uid) + volume = try c.decodeIfPresent(String.self, forKey: .volume) + style = try c.decodeIfPresent(StatStyle.self, forKey: .style) ?? .bar + if let page, !page.styles.contains(style) { style = page.styles[0] } + enabled = try c.decodeIfPresent(Bool.self, forKey: .enabled) ?? true + } +} + +nonisolated struct Slot { + enum Role: Equatable { + case full + case ringFill + case ringFixed + case stacked + } + var row: Int + var role: Role +} + +struct StatLayout: RawRepresentable { + static let key = "statLayoutV3" + static let stats: [Page] = [.cpu, .gpu, .ram, .storage, .network] + static let `default` = StatLayout(items: stats.dropLast().map { Tile(page: $0) } + [.separator(), Tile(page: .network)]) + + var items: [Tile] + + init(items: [Tile]) { self.items = items } + init?(rawValue: String) { + guard let d = rawValue.data(using: .utf8), let decoded = try? JSONDecoder().decode([Tile].self, from: d) else { return nil } + var seen = Set() + let kept = decoded.filter { t in + guard let p = t.page else { return true } + guard Self.stats.contains(p) else { return false } + return t.isExtraDrive || seen.insert(p).inserted + } + items = kept + Self.stats.filter { !seen.contains($0) }.map { Tile(page: $0) } + } + var rawValue: String { (try? JSONEncoder().encode(items)).flatMap { String(data: $0, encoding: .utf8) } ?? "" } + + private struct FlowRow { var rings: [Tile] = [], bars: [Tile] = [], wide: Tile? } + + private var flowRows: [FlowRow] { + var rows: [FlowRow] = [], cur = FlowRow() + func flush() { if !cur.rings.isEmpty || !cur.bars.isEmpty { rows.append(cur) }; cur = FlowRow() } + for t in items where t.enabled { + switch t.kind { + case .wide: flush(); rows.append(FlowRow(wide: t)) + case .ring: + if cur.rings.count >= 4 || (!cur.bars.isEmpty && cur.rings.count >= 2) || cur.bars.count > 2 { flush() } + cur.rings.append(t) + case .bar: + if !cur.rings.isEmpty && cur.bars.count >= 2 { flush() } + cur.bars.append(t) + } + } + flush() + return rows + } + + func arranged() -> [(tile: Tile, slot: Slot)] { + var slots: [String: Slot] = [:], n = 0 + for r in flowRows { + if let w = r.wide { + slots[w.id] = Slot(row: n, role: .full); n += 1 + } else if r.bars.isEmpty { + r.rings.forEach { slots[$0.id] = Slot(row: n, role: .ringFill) }; n += 1 + } else if r.rings.isEmpty { + for b in r.bars { slots[b.id] = Slot(row: n, role: .full); n += 1 } + } else { + r.rings.forEach { slots[$0.id] = Slot(row: n, role: .ringFixed) } + r.bars.forEach { slots[$0.id] = Slot(row: n, role: .stacked) } + n += 1 + } + } + return items.compactMap { t in slots[t.id].map { (t, $0) } } + } + + mutating func move(_ id: String, onto target: String) { + guard id != target, let from = items.firstIndex(where: { $0.id == id }), let to = items.firstIndex(where: { $0.id == target }) else { return } + items.insert(items.remove(at: from), at: to) + } +} + +private nonisolated struct SlotKey: LayoutValueKey { static let defaultValue = Slot(row: 0, role: .full) } + +extension View { + func slot(_ s: Slot) -> some View { layoutValue(key: SlotKey.self, value: s) } +} + +struct TileFlow: Layout { + static let ringW: CGFloat = 84 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let w = proposal.width ?? 300 + return CGSize(width: w, height: arrange(w, subviews).height) + } + + func placeSubviews(in b: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + for (i, f) in arrange(b.width, subviews).frames.enumerated() { + subviews[i].place(at: CGPoint(x: b.minX + f.minX, y: b.minY + f.minY), anchor: .topLeading, proposal: ProposedViewSize(width: f.width, height: f.height)) + } + } + + private func arrange(_ W: CGFloat, _ subviews: Subviews) -> (frames: [CGRect], height: CGFloat) { + var frames = Array(repeating: CGRect.zero, count: subviews.count) + let rows = Dictionary(grouping: subviews.indices) { subviews[$0][SlotKey.self].row } + func height(_ i: Int, _ w: CGFloat) -> CGFloat { subviews[i].sizeThatFits(ProposedViewSize(width: w, height: nil)).height } + var y: CGFloat = 0 + for r in rows.keys.sorted() { + let idx = rows[r]! + func with(_ role: Slot.Role) -> [Int] { idx.filter { subviews[$0][SlotKey.self].role == role } } + let full = with(.full), fill = with(.ringFill), fixed = with(.ringFixed), stacked = with(.stacked) + var h: CGFloat = 0 + if let i = full.first { + h = height(i, W) + frames[i] = CGRect(x: 0, y: y, width: W, height: h) + } else if !fill.isEmpty { + let w = W / CGFloat(fill.count), hs = fill.map { height($0, w) } + h = hs.max() ?? 0 + for (k, i) in fill.enumerated() { frames[i] = CGRect(x: CGFloat(k) * w, y: y + (h - hs[k]) / 2, width: w, height: hs[k]) } + } else { + let rw = Self.ringW, sw = W - rw * CGFloat(fixed.count) + let rhs = fixed.map { height($0, rw) }, shs = stacked.map { height($0, sw) } + let stackH = shs.reduce(0, +) + h = max(rhs.max() ?? 0, stackH) + for (k, i) in fixed.enumerated() { frames[i] = CGRect(x: CGFloat(k) * rw, y: y + (h - rhs[k]) / 2, width: rw, height: rhs[k]) } + var sy = y + (h - stackH) / 2 + for (k, i) in stacked.enumerated() { frames[i] = CGRect(x: rw * CGFloat(fixed.count), y: sy, width: sw, height: shs[k]); sy += shs[k] } + } + y += h + } + return (frames, y) + } +} + +extension Tile { + func reading(_ s: Snapshot) -> (Double, String) { + guard let page else { return (0, "") } + if page == .storage { + guard let v = s.volume(volume) else { return (0, String(localized: "Not connected")) } + return (v.fraction, bytes(v.used)) + } + return page.reading(s) + } +}