Add grid container display modes
| | |
| | | Text(tr("settings.flat")).tag("flat") |
| | | Text(tr("settings.container")).tag("container") |
| | | Text(tr("settings.coloredContainer")).tag("coloredContainer") |
| | | Text(tr("settings.gridContainer")).tag("gridContainer") |
| | | Text(tr("settings.coloredGridContainer")).tag("coloredGridContainer") |
| | | } |
| | | .pickerStyle(.segmented) |
| | | .frame(width: 360, alignment: .leading) |
| | | .frame(width: 520, alignment: .leading) |
| | | Text(tr("settings.flatDesc")) |
| | | .font(.caption).foregroundStyle(.secondary) |
| | | .fixedSize(horizontal: false, vertical: true) |
| | |
| | | tagPosition == "left" || tagPosition == "right" |
| | | } |
| | | |
| | | private var isColorlessContainerMode: Bool { |
| | | displayMode == "container" || displayMode == "gridContainer" |
| | | } |
| | | |
| | | private var isColoredContainerMode: Bool { |
| | | displayMode == "coloredContainer" || displayMode == "coloredGridContainer" |
| | | } |
| | | |
| | | var body: some View { |
| | | ZStack { |
| | | VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) |
| | |
| | | ForEach(tagLabels) { tag in |
| | | TagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | action: { |
| | | if displayMode == "container" { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | |
| | | ForEach(tagLabels) { tag in |
| | | SideTagPill(name: tag.name, colorIndex: tag.colorIndex, |
| | | action: { |
| | | if displayMode == "container" { |
| | | if isColorlessContainerMode { |
| | | toggleColorlessFill(tag.id) |
| | | } |
| | | scrollTo(tag.id) |
| | |
| | | Spacer() |
| | | ProgressView().scaleEffect(0.8) |
| | | Spacer() |
| | | } else if displayMode == "gridContainer" || displayMode == "coloredGridContainer" { |
| | | gridContainerGrid |
| | | } else if displayMode == "container" || displayMode == "coloredContainer" { |
| | | containerGrid |
| | | } else { |
| | |
| | | |
| | | private func masonryCard(_ group: TagGroup, width: CGFloat) -> some View { |
| | | let isColored = displayMode == "coloredContainer" |
| | | let isColorless = displayMode == "container" |
| | | let isColorless = isColorlessContainerMode |
| | | let isColorlessFilled = isColorless && filledColorlessContainer == group.name |
| | | let isHovered = hoveredContainer == group.name |
| | | let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0)) |
| | |
| | | } |
| | | } |
| | | |
| | | private var gridContainerGrid: some View { |
| | | GeometryReader { geo in |
| | | let outerPad: CGFloat = 20 |
| | | let gap: CGFloat = 16 |
| | | let available = geo.size.width - outerPad * 2 |
| | | let preferredCount = preferredGridContainersPerRow(availableWidth: available) |
| | | let rows = gridContainerRows(groups: groups, preferredCount: preferredCount) |
| | | |
| | | ScrollViewReader { proxy in |
| | | ScrollView { |
| | | LazyVStack(alignment: .leading, spacing: gap) { |
| | | ForEach(rows.indices, id: \.self) { rowIndex in |
| | | let row = rows[rowIndex] |
| | | let rowCount = max(1, row.count) |
| | | let cardWidth = (available - gap * CGFloat(rowCount - 1)) / CGFloat(rowCount) |
| | | let fixedRows = row.map { |
| | | iconRows(appCount: $0.apps.count, width: cardWidth) |
| | | }.max() ?? 1 |
| | | |
| | | HStack(alignment: .top, spacing: gap) { |
| | | ForEach(row) { group in |
| | | gridContainerCard(group, width: cardWidth, fixedRows: fixedRows) |
| | | .id(group.id) |
| | | } |
| | | } |
| | | } |
| | | } |
| | | .padding(outerPad) |
| | | .frame(maxWidth: .infinity, alignment: .topLeading) |
| | | } |
| | | .id(displayMode) |
| | | .onAppear { scrollProxy = proxy } |
| | | } |
| | | } |
| | | } |
| | | |
| | | private func preferredGridContainersPerRow(availableWidth: CGFloat) -> Int { |
| | | let minCardWidth = max(260, iconSize * 3 + 88) |
| | | if availableWidth >= minCardWidth * 3 + 32 { return 3 } |
| | | if availableWidth >= minCardWidth * 2 + 16 { return 2 } |
| | | return 1 |
| | | } |
| | | |
| | | private func gridContainerRows(groups: [TagGroup], preferredCount: Int) -> [[TagGroup]] { |
| | | var rows: [[TagGroup]] = [] |
| | | var index = 0 |
| | | while index < groups.count { |
| | | let remaining = groups.count - index |
| | | let count = min(max(1, preferredCount), remaining) |
| | | rows.append(Array(groups[index..<index + count])) |
| | | index += count |
| | | } |
| | | return rows |
| | | } |
| | | |
| | | private func iconColumns(width: CGFloat) -> Int { |
| | | let inner = width - 32 |
| | | let itemW = iconSize + 34 |
| | | return max(1, Int((inner + 6) / itemW)) |
| | | } |
| | | |
| | | private func iconRows(appCount: Int, width: CGFloat) -> Int { |
| | | let cols = iconColumns(width: width) |
| | | return max(1, (appCount + cols - 1) / cols) |
| | | } |
| | | |
| | | private func gridContainerCard(_ group: TagGroup, width: CGFloat, fixedRows: Int) -> some View { |
| | | let isColored = displayMode == "coloredGridContainer" |
| | | let isColorless = isColorlessContainerMode |
| | | let isColorlessFilled = isColorless && filledColorlessContainer == group.name |
| | | let isHovered = hoveredContainer == group.name |
| | | let cols = iconColumns(width: width) |
| | | let maxCells = max(cols, fixedRows * cols) |
| | | let emptyCells = max(0, maxCells - group.apps.count) |
| | | let tagColor = Color(nsColor: TagColor.nsColor(for: tagColors[group.name] ?? 0)) |
| | | |
| | | return VStack(alignment: .leading, spacing: 6) { |
| | | HStack(spacing: 0) { |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | .layoutPriority(0) |
| | | Text(group.name) |
| | | .font(.system(size: tagFontSize, weight: .semibold)) |
| | | .foregroundStyle(.secondary) |
| | | .lineLimit(1) |
| | | .truncationMode(.middle) |
| | | .padding(.horizontal, 10) |
| | | .layoutPriority(1) |
| | | Rectangle().fill(.secondary.opacity(0.25)).frame(height: 1) |
| | | .layoutPriority(0) |
| | | } |
| | | |
| | | LazyVGrid( |
| | | columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: cols), |
| | | spacing: 2 |
| | | ) { |
| | | ForEach(group.apps) { app in |
| | | AppGridItem(app: app, iconSize: iconSize, showName: !hideAppNames, onSelect: { openApp(app) }) |
| | | } |
| | | ForEach(0..<emptyCells, id: \.self) { _ in |
| | | Color.clear |
| | | .frame(width: iconSize + 20, height: iconSize + (hideAppNames ? 16 : 42)) |
| | | } |
| | | } |
| | | } |
| | | .frame(width: width) |
| | | .padding(16) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .fill((isColored || isColorlessFilled) ? tagColor.opacity(0.30) : Color.clear) |
| | | .background( |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .fill(.ultraThinMaterial) |
| | | ) |
| | | ) |
| | | .overlay( |
| | | RoundedRectangle(cornerRadius: 14) |
| | | .stroke(Color.primary.opacity(0.08), lineWidth: 1) |
| | | ) |
| | | .shadow(color: .black.opacity(isColored && isHovered ? 0.22 : 0), |
| | | radius: isColored && isHovered ? 18 : 0, |
| | | y: isColored && isHovered ? 10 : 0) |
| | | .scaleEffect(isColored && isHovered ? 1.015 : 1.0) |
| | | .animation(.spring(response: 0.25, dampingFraction: 0.82), value: isHovered) |
| | | .onHover { hovering in |
| | | if isColored { |
| | | hoveredContainer = hovering ? group.name : nil |
| | | } else if hovering { |
| | | fillColorlessContainer(group.name) |
| | | } |
| | | } |
| | | .contentShape(RoundedRectangle(cornerRadius: 14)) |
| | | .onTapGesture { |
| | | if isColorlessFilled { |
| | | filledColorlessContainer = nil |
| | | } |
| | | } |
| | | } |
| | | |
| | | // MARK: - Edit Tags View |
| | | |
| | | private var editTagsView: some View { |
| | |
| | | <key>CFBundlePackageType</key> |
| | | <string>APPL</string> |
| | | <key>CFBundleShortVersionString</key> |
| | | <string>3.2.1</string> |
| | | <string>4.0.0</string> |
| | | <key>CFBundleVersion</key> |
| | | <string>1</string> |
| | | <key>LSMinimumSystemVersion</key> |
| | |
| | | "settings.appListStyle": "App list style:", |
| | | "settings.flat": "Flat", |
| | | "settings.container": "Colorless Container", |
| | | "settings.flatDesc": "\"Flat\" shows apps directly. \"Colorless Container\" wraps each tag group in a rounded box. \"Colored Container\" fills each container with its tag color.", |
| | | "settings.flatDesc": "\"Flat\" shows apps directly. \"Colorless Container\" and \"Colored Container\" use masonry containers. \"Colorless Grid Container\" and \"Colored Grid Container\" align app icons in equal-height grid rows.", |
| | | "settings.hideAppNames": "Hide app names", |
| | | "settings.tagPosition": "Tag position:", |
| | | "settings.left": "Left", |
| | |
| | | "tag.entertainment": "Entertainment", |
| | | "tag.system": "System", |
| | | "tag.productivity": "Productivity", |
| | | "settings.coloredContainer": "Colored Container" |
| | | "settings.coloredContainer": "Colored Container", |
| | | "settings.gridContainer": "Colorless Grid Container", |
| | | "settings.coloredGridContainer": "Colored Grid Container" |
| | | } |
| | |
| | | "settings.appListStyle": "Estilo de lista de apps:", |
| | | "settings.flat": "Plano", |
| | | "settings.container": "Contenedor sin color", |
| | | "settings.flatDesc": "\"Plano\" muestra las apps directamente. \"Contenedor sin color\" coloca cada grupo en un contenedor sin relleno. \"Contenedor coloreado\" rellena cada contenedor con el color de la etiqueta.", |
| | | "settings.flatDesc": "\"Plano\" muestra las apps directamente. \"Contenedor sin color\" y \"Contenedor coloreado\" usan contenedores en cascada. \"Contenedor de cuadrícula sin color\" y \"Contenedor de cuadrícula coloreado\" alinean los iconos en filas de cuadrícula de igual altura.", |
| | | "settings.hideAppNames": "Ocultar nombres de apps", |
| | | "settings.tagPosition": "Posición de etiquetas:", |
| | | "settings.left": "Izquierda", |
| | |
| | | "tag.entertainment": "Entretenimiento", |
| | | "tag.system": "Sistema", |
| | | "tag.productivity": "Oficina", |
| | | "settings.coloredContainer": "Contenedor coloreado" |
| | | "settings.coloredContainer": "Contenedor coloreado", |
| | | "settings.gridContainer": "Contenedor de cuadrícula sin color", |
| | | "settings.coloredGridContainer": "Contenedor de cuadrícula coloreado" |
| | | } |
| | |
| | | "settings.appListStyle": "Style de liste d'applications :", |
| | | "settings.flat": "Liste", |
| | | "settings.container": "Conteneur sans couleur", |
| | | "settings.flatDesc": "\"Liste\" affiche les apps directement. \"Conteneur sans couleur\" place chaque groupe dans un conteneur sans remplissage. \"Conteneur coloré\" remplit chaque conteneur avec la couleur du tag.", |
| | | "settings.flatDesc": "\"Liste\" affiche les apps directement. \"Conteneur sans couleur\" et \"Conteneur coloré\" utilisent des conteneurs en cascade. \"Conteneur grille sans couleur\" et \"Conteneur grille coloré\" alignent les icônes dans des lignes de grille de hauteur égale.", |
| | | "settings.hideAppNames": "Masquer les noms d'apps", |
| | | "settings.tagPosition": "Position des étiquettes :", |
| | | "settings.left": "Gauche", |
| | |
| | | "tag.entertainment": "Divertissement", |
| | | "tag.system": "Système", |
| | | "tag.productivity": "Bureau", |
| | | "settings.coloredContainer": "Conteneur coloré" |
| | | "settings.coloredContainer": "Conteneur coloré", |
| | | "settings.gridContainer": "Conteneur grille sans couleur", |
| | | "settings.coloredGridContainer": "Conteneur grille coloré" |
| | | } |
| | |
| | | "settings.appListStyle": "Stile elenco app:", |
| | | "settings.flat": "Elenco", |
| | | "settings.container": "Contenitore senza colore", |
| | | "settings.flatDesc": "\"Elenco\" mostra le app direttamente. \"Contenitore senza colore\" inserisce ogni gruppo in un contenitore senza riempimento. \"Contenitore colorato\" riempie ogni contenitore con il colore del tag.", |
| | | "settings.flatDesc": "\"Elenco\" mostra le app direttamente. \"Contenitore senza colore\" e \"Contenitore colorato\" usano contenitori a cascata. \"Contenitore griglia senza colore\" e \"Contenitore griglia colorato\" allineano le icone in righe griglia di uguale altezza.", |
| | | "settings.hideAppNames": "Nascondi nomi app", |
| | | "settings.tagPosition": "Posizione tag:", |
| | | "settings.left": "Sinistra", |
| | |
| | | "tag.entertainment": "Intrattenimento", |
| | | "tag.system": "Sistema", |
| | | "tag.productivity": "Ufficio", |
| | | "settings.coloredContainer": "Contenitore colorato" |
| | | "settings.coloredContainer": "Contenitore colorato", |
| | | "settings.gridContainer": "Contenitore griglia senza colore", |
| | | "settings.coloredGridContainer": "Contenitore griglia colorato" |
| | | } |
| | |
| | | "settings.appListStyle": "アプリ一覧スタイル:", |
| | | "settings.flat": "フラット", |
| | | "settings.container": "無色コンテナ", |
| | | "settings.flatDesc": "\"フラット\"はアプリを直接表示します。\"無色コンテナ\"は各タググループを塗りなしのコンテナで囲みます。\"カラーコンテナ\"は各コンテナをタグ色で塗ります。", |
| | | "settings.flatDesc": "\"フラット\"はアプリを直接表示します。\"無色コンテナ\"と\"カラーコンテナ\"はコンテナを滝状に配置します。\"無色グリッドコンテナ\"と\"カラーグリッドコンテナ\"は等高のグリッド行でアプリアイコンを揃えます。", |
| | | "settings.hideAppNames": "アプリ名を非表示", |
| | | "settings.tagPosition": "タグ位置:", |
| | | "settings.left": "左", |
| | |
| | | "tag.entertainment": "エンタメ", |
| | | "tag.system": "システム", |
| | | "tag.productivity": "オフィス", |
| | | "settings.coloredContainer": "カラーコンテナ" |
| | | "settings.coloredContainer": "カラーコンテナ", |
| | | "settings.gridContainer": "無色グリッドコンテナ", |
| | | "settings.coloredGridContainer": "カラーグリッドコンテナ" |
| | | } |
| | |
| | | "settings.appListStyle": "앱 목록 스타일:", |
| | | "settings.flat": "플랫", |
| | | "settings.container": "무색 컨테이너", |
| | | "settings.flatDesc": "\"플랫\"은 앱을 직접 표시합니다. \"무색 컨테이너\"는 각 태그 그룹을 채움 없는 컨테이너로 묶습니다. \"색상 컨테이너\"는 각 컨테이너를 태그 색으로 채웁니다.", |
| | | "settings.flatDesc": "\"플랫\"은 앱을 직접 표시합니다. \"무색 컨테이너\"와 \"색상 컨테이너\"는 컨테이너를 폭포식으로 배치합니다. \"무색 그리드 컨테이너\"와 \"색상 그리드 컨테이너\"는 같은 높이의 그리드 행으로 앱 아이콘을 정렬합니다.", |
| | | "settings.hideAppNames": "앱 이름 숨기기", |
| | | "settings.tagPosition": "태그 위치:", |
| | | "settings.left": "왼쪽", |
| | |
| | | "tag.entertainment": "엔터테인먼트", |
| | | "tag.system": "시스템", |
| | | "tag.productivity": "오피스", |
| | | "settings.coloredContainer": "색상 컨테이너" |
| | | "settings.coloredContainer": "색상 컨테이너", |
| | | "settings.gridContainer": "무색 그리드 컨테이너", |
| | | "settings.coloredGridContainer": "색상 그리드 컨테이너" |
| | | } |
| | |
| | | "settings.appListStyle": "Стиль списка приложений:", |
| | | "settings.flat": "Плоский", |
| | | "settings.container": "Бесцветный контейнер", |
| | | "settings.flatDesc": "\"Плоский\" показывает приложения напрямую. \"Бесцветный контейнер\" помещает группы тегов в контейнеры без заливки. \"Цветной контейнер\" заливает каждый контейнер цветом тега.", |
| | | "settings.flatDesc": "\"Плоский\" показывает приложения напрямую. \"Бесцветный контейнер\" и \"Цветной контейнер\" используют каскадные контейнеры. \"Бесцветный сеточный контейнер\" и \"Цветной сеточный контейнер\" выравнивают значки приложений в строках сетки одинаковой высоты.", |
| | | "settings.hideAppNames": "Скрыть названия приложений", |
| | | "settings.tagPosition": "Положение тегов:", |
| | | "settings.left": "Слева", |
| | |
| | | "tag.entertainment": "Развлечения", |
| | | "tag.system": "Система", |
| | | "tag.productivity": "Офис", |
| | | "settings.coloredContainer": "Цветной контейнер" |
| | | "settings.coloredContainer": "Цветной контейнер", |
| | | "settings.gridContainer": "Бесцветный сеточный контейнер", |
| | | "settings.coloredGridContainer": "Цветной сеточный контейнер" |
| | | } |
| | |
| | | "settings.appListStyle": "应用列表样式:", |
| | | "settings.flat": "平铺", |
| | | "settings.container": "无色容器", |
| | | "settings.flatDesc": "\"平铺\"直接显示应用。\"无色容器\"将每个标签组放入无填充容器。\"彩色容器\"会用标签颜色填充每个容器。", |
| | | "settings.flatDesc": "\"平铺\"直接显示应用。\"无色容器\"和\"彩色容器\"使用瀑布流容器。\"无色网格容器\"和\"彩色网格容器\"会按等高网格行对齐应用图标。", |
| | | "settings.hideAppNames": "隐藏应用名称", |
| | | "settings.tagPosition": "标签位置:", |
| | | "settings.left": "左侧", |
| | |
| | | "tag.entertainment": "娱乐", |
| | | "tag.system": "系统优化", |
| | | "tag.productivity": "办公", |
| | | "settings.coloredContainer": "彩色容器" |
| | | "settings.coloredContainer": "彩色容器", |
| | | "settings.gridContainer": "无色网格容器", |
| | | "settings.coloredGridContainer": "彩色网格容器" |
| | | } |
| | |
| | | "settings.appListStyle": "應用程式列表樣式:", |
| | | "settings.flat": "平鋪", |
| | | "settings.container": "無色容器", |
| | | "settings.flatDesc": "\"平鋪\"直接顯示應用程式。\"無色容器\"將每個標籤組放入無填充容器。\"彩色容器\"會用標籤顏色填充每個容器。", |
| | | "settings.flatDesc": "\"平鋪\"直接顯示應用程式。\"無色容器\"和\"彩色容器\"使用瀑布流容器。\"無色網格容器\"和\"彩色網格容器\"會按等高網格列對齊應用程式圖示。", |
| | | "settings.hideAppNames": "隱藏應用程式名稱", |
| | | "settings.tagPosition": "標籤位置:", |
| | | "settings.left": "左側", |
| | |
| | | "tag.entertainment": "娛樂", |
| | | "tag.system": "系統優化", |
| | | "tag.productivity": "辦公", |
| | | "settings.coloredContainer": "彩色容器" |
| | | "settings.coloredContainer": "彩色容器", |
| | | "settings.gridContainer": "無色網格容器", |
| | | "settings.coloredGridContainer": "彩色網格容器" |
| | | } |
| | |
| | | # Apptag Changelog |
| | | |
| | | ## [4.0.0] — 2026-05-10 |
| | | |
| | | - 新增「无色网格容器」和「彩色网格容器」两种 App 列表视图样式 |
| | | - 网格容器按标签顺序分行排列,每行 1/2/3 个等宽容器,同一行等高,容器内通过空白网格位保持图标对齐 |
| | | - 无色网格容器继承无色容器的持久填充/点击清除交互;彩色网格容器继承彩色容器的默认填色/hover 阴影交互 |
| | | - 补充 9 语种新增视图名称与说明文案 |
| | | |
| | | ## [3.2.1] — 2026-05-10 |
| | | |
| | | - 固定「无色容器」交互逻辑:hover 标签或容器时保持标签色填充,再次点击标签或点击容器空白处清除填充 |