diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 455aef7..5bb7bdc 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the release workflow uses the section matching the tag and fails if either is missing. ## [Unreleased] +### Changes +- **Settings as one wide column of icon-only tiles** (#293, replaces the two-column design of 0.14): every setting is now an icon-only tile, with no text beside it, in groups inside a single wide column (no parallel columns). The focused tile fills in and its **name, value and what it does** show in the panel's fixed reading area (and in a hover tooltip); Narrator still reads name and value. L1/R1 jump between groups and Configurações stays the first tile of the Menu. No setting was removed or changed behavior. ## [0.14.0-alpha.1] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index b654337..c0a07b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` **nos dois arquivos**. O workflow de release usa a seção da tag e falha se faltar alguma. ## [Unreleased] +### Mudanças +- **Configurações numa coluna larga, só com ícones** (#293, refaz o desenho em duas colunas da 0.14): todos os ajustes viram blocos só de ícone, sem texto ao lado, em grupos numa única coluna larga (sem colunas paralelas). O bloco em foco se preenche e o **nome, o valor e o que o ajuste faz** aparecem na área de leitura fixa do painel (e numa dica ao passar o mouse); o Narrador continua lendo nome e valor. L1/R1 pulam de grupo e Configurações segue como primeiro bloco do Menu. Nenhum ajuste foi removido ou mudou de comportamento. ## [0.14.0-alpha.1] ### Novidades diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 6c9412f..be47f78 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -45,7 +45,7 @@ Item actions (North) and the app Menu (Start) open with the most used actions as In dialogs, the footer says what each button does. With the focus on the back option itself (**Cancelar**, **Fechar**), the footer shows the next option on Right instead of repeating the same thing on both buttons. -**Option pickers:** a setting with several values that the tile or row doesn't show (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) opens a picker listing every choice, each with a short explanation, the current one marked with a filled radio icon and the word "atual" and the focus on it. South/Enter or a click on a choice applies it and returns to Configurações on the same setting; Back (or Esc) closes the picker without changing anything. Simple on/off settings and settings that open their own screen stay one press. The same picker is used for the size and date filters of a search and the mode of batch rename. All settings live in Menu → **Configurações** (settings), grouped under **Exibição** (view, density, details panel, sort, hidden items), **Busca e privacidade** (search in subfolders, recents, keyboard suggestions), **Controles** (confirm button, button labels, Fluidez (controller read rate), Sons do controle (controller sounds), Leve em segundo plano (light in the background), gyro aiming (experimental), active controller, controller test, controllers without a profile) and **ControlFS** (updates). In each group, short settings are **tiles** in a grid with the icon, a short name and the current value (e.g. **Ocultos** · escondidos, hidden items · hidden). Values are states, never verbs, and on/off settings say **ligado**/**desligado** (on/off); the same setting has the same name and value everywhere. The line under the focused tile explains what the setting does instead of repeating its value. Settings with long descriptions or that open another screen stay in a list below. In the grid, Left/Right move between tiles, Down from the last row continues to the group's list (and from there to the next group's grid) and Up goes back to the tile you came from. Every window of the app keeps its size while you use it: the panel never widens, narrows, grows or shrinks as focus moves, values change, a message appears or you switch options (for example ZIP, TAR.GZ and 7z in the Compress window are the same size); long texts wrap inside it, and the description area is as tall as the longest description. Changing a setting keeps Configurações open with the new value and focus on the same setting, so you can change several in a row; Back closes it. +**Option pickers:** a setting with several values that the tile or row doesn't show (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) opens a picker listing every choice, each with a short explanation, the current one marked with a filled radio icon and the word "atual" and the focus on it. South/Enter or a click on a choice applies it and returns to Configurações on the same setting; Back (or Esc) closes the picker without changing anything. Simple on/off settings and settings that open their own screen stay one press. The same picker is used for the size and date filters of a search and the mode of batch rename. All settings live in Menu → **Configurações** (settings), grouped under **Exibição** (view, density, details panel, sort, hidden items), **Busca e privacidade** (search in subfolders, recents, keyboard suggestions), **Controles** (confirm button, button labels, Fluidez (controller read rate), Sons do controle (controller sounds), Leve em segundo plano (light in the background), gyro aiming (experimental), active controller, controller test, controllers without a profile) and **ControlFS** (updates). The settings are one wide column of **icon-only tiles**, in groups: no text sits beside an icon. The focused tile fills with the accent colour (red if dangerous, grey if unavailable) and the reading area under the grid shows its **name and current value** in large type, with what it does below (a mouse hover shows the same in a tooltip; Narrator reads the name and value). Values are states, never verbs, and on/off settings say **ligado**/**desligado** (on/off); the same setting has the same name and value everywhere. Left/Right move between tiles, Up/Down between rows and, past the last row, into the next group (same column); **L1/R1** jump group to group. Menu → **Configurações** is the first tile of the Menu. Every window of the app keeps its size while you use it: the panel never widens, narrows, grows or shrinks as focus moves, values change, a message appears or you switch options (for example ZIP, TAR.GZ and 7z in the Compress window are the same size); long texts wrap inside it, and the description area is as tall as the longest description. Changing a setting keeps Configurações open with the new value and focus on the same setting, so you can change several in a row; Back closes it. ### Controller test diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md index 46416d6..d7282f7 100644 --- a/docs/GUIDE.pt-BR.md +++ b/docs/GUIDE.pt-BR.md @@ -45,7 +45,7 @@ As ações do item (Norte) e o Menu do app (Start) abrem com as ações mais usa Nos diálogos, o rodapé diz o que cada botão faz. Com o foco na própria opção de voltar (**Cancelar**, **Fechar**), o rodapé mostra a próxima opção na seta para a direita em vez de repetir a mesma coisa nos dois botões. -**Seletor de opções:** um ajuste com vários valores que o bloco ou a linha não mostram (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) abre um seletor com todas as escolhas, cada uma com uma explicação curta, a atual marcada com um botão de opção cheio e a palavra "atual" e o foco nela. Sul/Enter ou um clique numa escolha a aplica e volta a Configurações no mesmo ajuste; Voltar (ou Esc) fecha o seletor sem mudar nada. Ligar/desligar simples e ajustes que abrem a própria tela continuam de um toque. O mesmo seletor vale para o tamanho e a data dos filtros da busca e para o modo de Renomear em lote. Todos os ajustes ficam em Menu → **Configurações**, em grupos: **Exibição** (lista/grade, densidade, painel de detalhes, ordenação, itens ocultos), **Busca e privacidade** (busca em subpastas, recentes, sugestões do teclado), **Controles** (botão de confirmar, legendas, Fluidez, Sons do controle, Leve em segundo plano, mira por giroscópio (experimental), controle ativo, teste de controles, controles sem perfil) e **ControlFS** (atualizações). Em cada grupo, os ajustes curtos são **blocos** numa grade, com o ícone, o nome curto e o valor atual (ex.: **Ocultos** · escondidos). Os valores são estados, nunca verbos, e ajustes de ligar/desligar dizem **ligado**/**desligado**; o mesmo ajuste tem o mesmo nome e valor em qualquer tela. A linha sob o bloco focado explica o que o ajuste faz em vez de repetir o valor. Os que têm descrição longa ou abrem outra tela ficam numa lista abaixo. Na grade, esquerda/direita andam entre os blocos, baixo na última linha segue para a lista do grupo (e dela para a grade do grupo seguinte) e cima volta ao bloco de onde você veio. Toda janela do app mantém o tamanho enquanto você a usa: o painel não alarga, estreita, cresce nem encolhe quando o foco se move, um valor muda, aparece um aviso ou você troca de opção (por exemplo, ZIP, TAR.GZ e 7z na janela Compactar têm o mesmo tamanho); textos longos quebram linha dentro dele, e a área de descrição tem a altura da descrição mais longa. Mudar um ajuste mantém Configurações aberto com o valor novo e o foco no mesmo ajuste, para mudar vários seguidos; Voltar fecha. +**Seletor de opções:** um ajuste com vários valores que o bloco ou a linha não mostram (**Exibição**, **Densidade da lista**, **Tema**, **Cor de destaque**, **Ordenar por**, **Confirmar com**, **Legendas**, **Fluidez**) abre um seletor com todas as escolhas, cada uma com uma explicação curta, a atual marcada com um botão de opção cheio e a palavra "atual" e o foco nela. Sul/Enter ou um clique numa escolha a aplica e volta a Configurações no mesmo ajuste; Voltar (ou Esc) fecha o seletor sem mudar nada. Ligar/desligar simples e ajustes que abrem a própria tela continuam de um toque. O mesmo seletor vale para o tamanho e a data dos filtros da busca e para o modo de Renomear em lote. Todos os ajustes ficam em Menu → **Configurações**, em grupos: **Exibição** (lista/grade, densidade, painel de detalhes, ordenação, itens ocultos), **Busca e privacidade** (busca em subpastas, recentes, sugestões do teclado), **Controles** (botão de confirmar, legendas, Fluidez, Sons do controle, Leve em segundo plano, mira por giroscópio (experimental), controle ativo, teste de controles, controles sem perfil) e **ControlFS** (atualizações). Os ajustes são uma coluna larga de **blocos só de ícone**, em grupos: nenhum texto fica ao lado de um ícone. O bloco em foco se preenche com a cor de destaque (vermelho se perigoso, cinza se indisponível) e a área de leitura sob a grade mostra o **nome e o valor atual** em letra grande, com o que o ajuste faz logo abaixo (o mouse mostra o mesmo numa dica; o Narrador lê o nome e o valor). Os valores são estados, nunca verbos, e ajustes de ligar/desligar dizem **ligado**/**desligado**; o mesmo ajuste tem o mesmo nome e valor em qualquer tela. Esquerda/direita andam entre os blocos, cima/baixo entre as linhas e, depois da última, entram no grupo seguinte (mesma coluna); **L1/R1** pulam de grupo em grupo. Menu → **Configurações** é o primeiro bloco do Menu. Toda janela do app mantém o tamanho enquanto você a usa: o painel não alarga, estreita, cresce nem encolhe quando o foco se move, um valor muda, aparece um aviso ou você troca de opção (por exemplo, ZIP, TAR.GZ e 7z na janela Compactar têm o mesmo tamanho); textos longos quebram linha dentro dele, e a área de descrição tem a altura da descrição mais longa. Mudar um ajuste mantém Configurações aberto com o valor novo e o foco no mesmo ajuste, para mudar vários seguidos; Voltar fecha. ### Teste de controles diff --git a/docs/ui-redesign.md b/docs/ui-redesign.md index 77e7081..9702439 100644 --- a/docs/ui-redesign.md +++ b/docs/ui-redesign.md @@ -221,7 +221,7 @@ added line. | Where | Pickers | Stays one press | |---|---|---| | Compactar | Formato, Compressão | Nome (keyboard), Compactar | -| Configurações (paisagem, 2 colunas; L1/R1 pulam de grupo; primeiro bloco do Menu) | Exibição, Densidade da lista, Tema, Cor de destaque, Ordenar por, Confirmar com, Legendas, Fluidez | Ordem, Painel de detalhes, Itens ocultos, Tela cheia, Busca em subpastas, Recentes, Restaurar abas, Sugestões do teclado, Segundo plano, Mira por giroscópio (on/off or symmetric two-way toggles); Controle ativo, Teste de controles, Controles sem perfil, Atualizações (they open their own screen) | +| Configurações (uma coluna larga de blocos só de ícone, #293; L1/R1 pulam de grupo; primeiro bloco do Menu) | Exibição, Densidade da lista, Tema, Cor de destaque, Ordenar por, Confirmar com, Legendas, Fluidez | Ordem, Painel de detalhes, Itens ocultos, Tela cheia, Busca em subpastas, Recentes, Restaurar abas, Sugestões do teclado, Segundo plano, Mira por giroscópio (on/off or symmetric two-way toggles); Controle ativo, Teste de controles, Controles sem perfil, Atualizações (they open their own screen) | | Filtros da busca | Tamanho, Modificado | Tipos (checkboxes) | | Renomear em lote | Modo, Converter para | Dígitos (1–6, a stepper), text fields | diff --git a/src/ControlFS.App/Views/ModalView.cs b/src/ControlFS.App/Views/ModalView.cs index 9902b93..9d6092a 100644 --- a/src/ControlFS.App/Views/ModalView.cs +++ b/src/ControlFS.App/Views/ModalView.cs @@ -161,9 +161,6 @@ public static void ScrollBody(int steps) private static double PanelMargin => Theme.SpaceM; - /// Largura mínima do painel (px lógicos) para as Configurações ficarem em duas colunas (#285). - private const double MenuTwoColumnMinWidth = 900; - /// /// Largura fixa de cada classe de tamanho (#227, px lógicos antes da escala). Nunca depende do conteúdo em foco; a janela /// (720p, portátil, 4K) sempre limita (). @@ -174,7 +171,6 @@ public static void ScrollBody(int steps) ModalSize.Medium => 540, ModalSize.Standard => 640, ModalSize.Wide => 960, - ModalSize.Landscape => 1080, _ => double.PositiveInfinity, }; @@ -550,10 +546,10 @@ private static UIElement WeightStable(TextBlock text, bool focused) /// private sealed class TextSlot { - private readonly Dictionary _blocks = []; - private readonly Func _create; + private readonly Dictionary _blocks = []; + private readonly Func _create; - public TextSlot(IEnumerable reserved, Func create) + public TextSlot(IEnumerable reserved, Func create) { _create = create; foreach (var text in reserved) Ensure(text).Opacity = 0; @@ -561,7 +557,7 @@ public TextSlot(IEnumerable reserved, Func create) public Grid Root { get; } = new(); - private TextBlock Ensure(string text) + private FrameworkElement Ensure(string text) { if (_blocks.TryGetValue(text, out var block)) return block; block = _create(text); @@ -611,13 +607,6 @@ private static StackPanel SectionHeading(string? title, bool first) private static Border BuildMenu(AppController app, MenuModal menu, out Action update) { var stack = new StackPanel(); - // Configurações em paisagem (#285): com largura para isso, os grupos ficam em duas colunas lado a lado (a de baixo - // continua a de cima em ordem de leitura). Numa janela estreita, uma coluna só, como sempre. - var twoColumns = menu.HasSectionGrids && PanelWidthFor(menu.Size) >= Theme.Scaled(MenuTwoColumnMinWidth); - var second = twoColumns ? new StackPanel() : null; - var split = twoColumns ? menu.ColumnSplit : int.MaxValue; - var target = stack; - var owners = new StackPanel[menu.Items.Count]; var hasGrid = menu.Grids.Count > 0; // Títulos de grupo: em menus só de lista e nas Configurações; com a grade de ações rápidas no topo, só o fio (como o // menu de contexto do Windows 11). @@ -627,20 +616,18 @@ private static Border BuildMenu(AppController app, MenuModal menu, out Action up string? section = null; for (var i = 0; i < menu.Items.Count;) { - if (i >= split && second is not null && !ReferenceEquals(target, second)) target = second; var item = menu.Items[i]; var grid = menu.GridOf(i); // Grupo novo: título (ou só o fio). Menu comum: logo abaixo da grade do topo sempre há o fio separando as duas partes. var heading = menu.HasSectionGrids || !hasGrid ? (i == 0 ? item.Section is not null : item.Section != section) : i != 0 && (i == menu.QuickCount || item.Section != section); - if (heading) target.Children.Add(SectionHeading(titles || (item.Section is not null && item.Section == menu.TitledSection) ? item.Section : null, first: target.Children.Count == 0)); + if (heading) stack.Children.Add(SectionHeading(titles || (item.Section is not null && item.Section == menu.TitledSection) ? item.Section : null, first: i == 0)); section = item.Section; if (grid is null) { - positions[i] = target.Children.Count; - owners[i] = target; - target.Children.Add(MenuRow(app, menu, i)); + positions[i] = stack.Children.Count; + stack.Children.Add(MenuRow(app, menu, i)); i++; continue; } @@ -648,27 +635,16 @@ private static Border BuildMenu(AppController app, MenuModal menu, out Action up for (var c = 0; c < grid.Columns; c++) tiles.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); for (var r = 0; r < grid.Rows; r++) tiles.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto }); for (var t = grid.Start; t < grid.End; t++) tiles.Children.Add(MenuTile(app, menu, t)); - target.Children.Add(tiles); + stack.Children.Add(tiles); grids[grid] = tiles; i = grid.End; } - UIElement body = stack; - if (second is not null) - { - var columns = new Grid { ColumnSpacing = Theme.SpaceL }; - columns.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - columns.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - Grid.SetColumn(second, 1); - columns.Children.Add(stack); - columns.Children.Add(second); - body = columns; - } if (menu.Items.Count == 0) stack.Children.Add(new TextBlock { Text = "Nenhuma opção.", FontSize = Theme.FontBody, Foreground = Theme.TextMuted }); // Descrição fixa da opção em foco (#227): a altura já é a da descrição mais longa; uma linha, no mínimo, se houver alguma. var lineHeight = Math.Ceiling(Theme.FontCaption * 1.4); - var description = new TextSlot(menu.AllDescriptions, text => new TextBlock { Text = text, FontSize = Theme.FontCaption, Foreground = Theme.TextMuted, TextWrapping = TextWrapping.Wrap }); + var description = new TextSlot(menu.AllDescriptions, text => menu.IconOnly ? IconReadout(text) : (FrameworkElement)new TextBlock { Text = text, FontSize = Theme.FontCaption, Foreground = Theme.TextMuted, TextWrapping = TextWrapping.Wrap }); UIElement? below = null; if (!menu.IsPicker && menu.AllDescriptions.Any()) { @@ -685,13 +661,13 @@ private static Border BuildMenu(AppController app, MenuModal menu, out Action up description.Show(menu.Description); Border? footer = null; - var card = Panel(app, Header(menu, compact: true), body, menu.Size, footerSink: f => footer = f, compact: true, below: below); + var card = Panel(app, Header(menu, compact: true), stack, menu.Size, footerSink: f => footer = f, compact: true, below: below); var shown = menu.FocusIndex; void Replace(int index) { if (index < 0 || index >= positions.Length) return; if (menu.GridOf(index) is { } grid) grids[grid].Children[index - grid.Start] = MenuTile(app, menu, index); - else owners[index].Children[positions[index]] = MenuRow(app, menu, index); + else stack.Children[positions[index]] = MenuRow(app, menu, index); } update = () => { @@ -708,6 +684,19 @@ void Replace(int index) return card; } + /// + /// Leitura do ajuste em foco numa grade só de ícones (#293): "Nome: valor" em destaque e, embaixo, o que ele faz. Fica sempre no + /// mesmo lugar e tem a altura do texto mais longo, então mover o foco nunca redimensiona o painel. + /// + private static StackPanel IconReadout(string text) + { + var lines = text.Split('\n', 2); + var panel = new StackPanel { Spacing = Theme.Space(2) }; + panel.Children.Add(new TextBlock { Text = lines[0], FontSize = Theme.FontItem, FontWeight = FontWeights.SemiBold, Foreground = Theme.Text, TextWrapping = TextWrapping.Wrap }); + panel.Children.Add(new TextBlock { Text = lines.Length > 1 ? lines[1] : " ", FontSize = Theme.FontCaption, Foreground = Theme.TextMuted, TextWrapping = TextWrapping.Wrap }); + return panel; + } + /// /// Bloco da grade: ícone grande e o rótulo curto visível embaixo (legível de longe; nada só em dica de mouse) e, num /// ajuste, o valor atual. Focado: preenchido (ciano; vermelho se perigoso; cinza se indisponível), texto escuro em negrito @@ -726,7 +715,8 @@ private static Border MenuTile(AppController app, MenuModal menu, int index) var iconInk = focused && enabled ? Theme.FocusText : !enabled ? (focused ? Theme.TextMuted : Theme.TextDisabled) : destructive ? Theme.Danger : Theme.Accent; var content = new StackPanel { Spacing = Theme.Space(6), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; - content.Children.Add(Glyph(ActionIcons.Glyph(item.Icon), Theme.Font(24), iconInk)); + content.Children.Add(Glyph(ActionIcons.Glyph(item.Icon), Theme.Font(menu.IconOnly ? 30 : 24), iconInk)); + if (menu.IconOnly) return IconTile(app, menu, index, content, fill, focused, enabled, destructive, grid); var text = new TextBlock { Text = item.TileLabel, @@ -786,6 +776,40 @@ private static Border MenuTile(AppController app, MenuModal menu, int index) return tile; } + /// + /// Bloco só de ícone (#293): nenhum texto fixo. O foco é o preenchimento cheio (ciano; vermelho se perigoso; cinza se + /// indisponível) mais o leve aumento, e o nome, o valor e a descrição aparecem na leitura fixa do painel. O Narrador ouve o + /// rótulo por extenso (nome e valor) e quem usa o mouse vê o mesmo numa dica. + /// + private static Border IconTile(AppController app, MenuModal menu, int index, UIElement content, Brush fill, bool focused, bool enabled, bool destructive, MenuGrid grid) + { + var item = menu.Items[index]; + var tile = new Border + { + Child = content, + Background = fill, + CornerRadius = Theme.RowRadius, + Padding = new Thickness(Theme.Space(4)), + MinHeight = Theme.Scaled(72), + BorderBrush = focused && !enabled ? Theme.Accent : null, + BorderThickness = focused && !enabled ? Theme.FocusRing : default, + }; + Grid.SetRow(tile, grid.Row(index)); + Grid.SetColumn(tile, grid.Column(index)); + if (focused) + { + tile.RenderTransformOrigin = new Point(0.5, 0.5); + tile.RenderTransform = new ScaleTransform { ScaleX = 1.05, ScaleY = 1.05 }; + AutomationProperties.SetAutomationId(tile, FocusedOptionId); + KeepInView(tile); + } + var name = item.Label + (enabled ? string.Empty : ", indisponível") + (destructive ? ", ação perigosa" : string.Empty); + AutomationProperties.SetName(tile, name); + ToolTipService.SetToolTip(tile, item.Label); + tile.Tapped += (_, _) => app.PointerChooseModalOption(index); + return tile; + } + /// Linha compacta da lista; o que ela faz (ou por que está indisponível) aparece na área de descrição fixa do painel. private static Border MenuRow(AppController app, MenuModal menu, int index) { diff --git a/src/ControlFS.Application/AppController.Browser.cs b/src/ControlFS.Application/AppController.Browser.cs index 63014f2..aba296c 100644 --- a/src/ControlFS.Application/AppController.Browser.cs +++ b/src/ControlFS.Application/AppController.Browser.cs @@ -611,7 +611,7 @@ .. UndoMenuItems(), /// (controle ativo, teste, joysticks, atualizações) fecham Configurações e abrem essa tela, como os outros submenus. /// internal void ShowSettings() => - PushModal(new MenuModal("Configurações", SettingsItems(), sectionGrids: true) { Icon = ActionIcon.Settings, Reload = SettingsItems }); + PushModal(new MenuModal("Configurações", SettingsItems(), sectionGrids: true, iconOnly: true) { Icon = ActionIcon.Settings, Reload = SettingsItems }); private List SettingsItems() { diff --git a/src/ControlFS.Application/State/Modals.cs b/src/ControlFS.Application/State/Modals.cs index 8065412..9cd9558 100644 --- a/src/ControlFS.Application/State/Modals.cs +++ b/src/ControlFS.Application/State/Modals.cs @@ -22,12 +22,6 @@ public enum ModalSize /// Teclado virtual, "Mais da equipe", assistente e teste de controles. Wide, - /// - /// Configurações (#285): paisagem. Os grupos ficam em duas colunas lado a lado quando a janela comporta (1280×720 e - /// maiores); numa janela estreita voltam a uma coluna só, como o tamanho Médio. - /// - Landscape, - /// A janela menos as margens (visualizações, boas-vindas). Fill, } @@ -86,18 +80,19 @@ public sealed record MenuItem(string Label, Action? Execute, string? DisabledRea } /// Uma grade de blocos: itens seguidos de a partir de . -public sealed record MenuGrid(int Start, int Count) +public sealed record MenuGrid(int Start, int Count, int MaxCols = MenuGrid.MaxColumns, int MinCols = 1) { /// Máximo de blocos por linha. public const int MaxColumns = 4; /// - /// Blocos por linha: até 4 numa linha só; mais que isso, duas (ou mais) linhas equilibradas de no máximo 4 (os rótulos - /// curtos cabem inteiros, legíveis de longe). Nove blocos (menu do app) ficam 3×3 em vez de 4+4+1. + /// Blocos por linha: até numa linha só; mais que isso, duas (ou mais) linhas equilibradas (os rótulos + /// curtos cabem inteiros, legíveis de longe). Nove blocos (menu do app) ficam 3×3 em vez de 4+4+1. Nunca menos que + /// : grupos de poucos ícones (Configurações, #293) mantêm o tamanho do bloco dos outros grupos. /// - public int Columns => Count <= MaxColumns ? Count - : Count > 2 * MaxColumns && Count % MaxColumns != 0 && Count % (MaxColumns - 1) == 0 ? MaxColumns - 1 - : Math.Min(MaxColumns, (Count + 1) / 2); + public int Columns => Math.Max(MinCols, Count <= MaxCols ? Count + : Count > 2 * MaxCols && Count % MaxCols != 0 && Count % (MaxCols - 1) == 0 ? MaxCols - 1 + : Math.Min(MaxCols, (Count + 1) / 2)); public int Rows => (Count + Columns - 1) / Columns; public int End => Start + Count; @@ -123,10 +118,17 @@ public sealed class MenuModal : Modal private readonly bool _sectionGrids; - public MenuModal(string title, IReadOnlyList items, bool sectionGrids = false) : base(title) + /// Blocos por linha numa grade só de ícones (Configurações, #293): largos o bastante para o ícone e o toque. + public const int IconColumns = 6; + + /// Menos blocos que isto numa linha nunca fica mais largo que o bloco de quatro: grupos curtos não esticam. + public const int IconMinColumns = 4; + + public MenuModal(string title, IReadOnlyList items, bool sectionGrids = false, bool iconOnly = false) : base(title) { Icon = ActionIcon.Menu; _sectionGrids = sectionGrids; + IconOnly = sectionGrids && iconOnly; Arrange(items); FocusIndex = items.Count == 0 ? 0 : InitialFocus(items[0]); } @@ -146,6 +148,12 @@ private int InitialFocus(MenuItem first) public IReadOnlyList Items { get; private set; } = []; public int FocusIndex { get; internal set; } + /// + /// Todos os itens são blocos só de ícone, numa coluna larga (Configurações, #293): sem texto fixo ao lado; o nome, o valor e + /// o que o ajuste faz aparecem na área de leitura do painel quando o bloco recebe o foco (e numa dica ao passar o mouse). + /// + public bool IconOnly { get; } + /// /// Onde começa cada grupo de Configurações (índice do primeiro item), na ordem de : R1/L1 pulam de um /// grupo para outro (#285). Vazio fora de menus com grupos. @@ -162,39 +170,6 @@ public IReadOnlyList SectionStarts } } - /// - /// Índice do primeiro item da coluna da direita quando os grupos ficam lado a lado (#285): o ponto de corte entre grupos que - /// deixa as duas colunas com alturas parecidas (tiles pesam mais que linhas). A ordem de navegação segue a de leitura em colunas: - /// Baixo no fim da primeira coluna vai ao topo da segunda. - /// - public int ColumnSplit - { - get - { - var starts = SectionStarts; - if (starts.Count < 2) return Items.Count; - double Weight(int from, int to) - { - double w = 1.2; // título do grupo - for (var i = from; i < to;) - { - if (GridOf(i) is { } grid) { w += grid.Rows * 2.0; i = grid.End; } - else { w += 1; i++; } - } - return w; - } - var total = Weight(0, Items.Count); - var best = starts[1]; - var bestGap = double.MaxValue; - foreach (var cut in starts.Skip(1)) - { - var gap = Math.Abs(Weight(0, cut) - (total - Weight(0, cut))); - if (gap < bestGap) (best, bestGap) = (cut, gap); - } - return best; - } - } - /// As grades do menu, na ordem de (menu comum: no máximo uma, no topo). public IReadOnlyList Grids { get; private set; } = []; @@ -223,7 +198,15 @@ double Weight(int from, int to) /// repetição do que o bloco já mostra (#227). Um ajuste já mostra nome curto e valor: a linha só explica. Uma ação com /// rótulo curto ganha o nome completo antes (ex.: "Colar 2 itens (mover)"). /// - public string TileCaption => IsQuick(FocusIndex) ? Caption(FocusIndex) : string.Empty; + public string TileCaption => IconOnly ? IconCaption(FocusIndex) : IsQuick(FocusIndex) ? Caption(FocusIndex) : string.Empty; + + /// Em grade só de ícones: "Nome: valor" numa linha e o que faz (ou por que não está disponível) na seguinte. + private string IconCaption(int index) + { + var item = Items[index]; + var about = !item.IsEnabled ? "Indisponível: " + item.DisabledReason : item.Detail; + return about is { Length: > 0 } ? item.Label + "\n" + about : item.Label; + } private string Caption(int index) { @@ -240,6 +223,7 @@ private string Caption(int index) public string DescriptionOf(int index) { if (index < 0 || index >= Items.Count) return string.Empty; + if (IconOnly) return IconCaption(index); if (IsQuick(index)) return Caption(index); var item = Items[index]; return !item.IsEnabled ? "Indisponível: " + item.DisabledReason : item.Detail ?? string.Empty; @@ -255,7 +239,7 @@ public string DescriptionOf(int index) public IEnumerable AllDescriptions => Enumerable.Range(0, Items.Count).Select(DescriptionOf).Where(t => t.Length > 0).Distinct(); /// Menu com grade ou seletor de opções: ; só lista: . Nunca pelo item em foco (#227). - public override ModalSize Size => _sectionGrids ? ModalSize.Landscape : Grids.Count > 0 || IsPicker ? ModalSize.Medium : ModalSize.Compact; + public override ModalSize Size => IconOnly ? ModalSize.Wide : Grids.Count > 0 || IsPicker ? ModalSize.Medium : ModalSize.Compact; /// /// Seletor de opções (#261): cada item é uma alternativa de um valor; é a atual (marcada com @@ -307,14 +291,14 @@ private void Arrange(IReadOnlyList items) var grids = new List(); for (var i = 0; i < items.Count;) { - if (!items[i].IsQuick) + if (!IconOnly && !items[i].IsQuick) { i++; continue; } var start = i; - while (i < items.Count && items[i].IsQuick && items[i].Section == items[start].Section) i++; - grids.Add(new MenuGrid(start, i - start)); + while (i < items.Count && (IconOnly || items[i].IsQuick) && items[i].Section == items[start].Section) i++; + grids.Add(IconOnly ? new MenuGrid(start, i - start, IconColumns, IconMinColumns) : new MenuGrid(start, i - start)); } Grids = grids; return; diff --git a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs index d0fed15..ed635f5 100644 --- a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs @@ -268,49 +268,47 @@ public void Settings_live_in_Configuracoes_and_a_toggle_keeps_it_open() => UiCon }); [Fact] - public void Settings_grids_per_section_navigate_in_two_dimensions_with_the_lists_between_them() => UiContext.Run(async () => + public void Settings_is_one_column_of_icon_only_grids_per_section_navigated_in_two_dimensions() => UiContext.Run(async () => { - // #227: ajustes curtos são blocos na grade do seu grupo (com o valor); os longos e os que abrem telas ficam na lista. + // #293: todos os ajustes são blocos só de ícone na grade do seu grupo, numa coluna larga; o foco revela nome e valor. var d = Boot(); var app = d.App; d.Press(InputAction.Confirm); await d.Idle(); app.ShowSettings(); var settings = await d.WaitMenu(); + Assert.True(settings.IconOnly); + Assert.Equal(ModalSize.Wide, settings.Size); // uma coluna larga (sem colunas paralelas) + Assert.Equal(4, settings.Grids.Count); + Assert.Equal(settings.Items.Count, settings.Grids.Sum(g => g.Count)); // nenhum ajuste fora da grade (nada de linhas de texto) var view = settings.Grids[0]; - Assert.Equal(["Exibição", "Densidade", "Detalhes", "Tema", "Destaque", "Ordenar", "Ordem", "Ocultos"], - settings.Items.Skip(view.Start).Take(view.Count).Select(i => i.TileLabel)); - Assert.Equal((4, 2), (view.Columns, view.Rows)); - Assert.Equal(3, settings.Grids.Count); - Assert.All(settings.Items.Where(i => i.IsQuick), i => Assert.False(string.IsNullOrEmpty(i.Value))); - Assert.False(settings.IsQuick(settings.Items.ToList().FindIndex(i => i.Label.StartsWith("Controle ativo", StringComparison.Ordinal)))); + Assert.Equal(9, view.Count); + Assert.Equal((5, 2), (view.Columns, view.Rows)); + Assert.Equal(4, settings.Grids[3].Columns); // grupo curto (2 ajustes): o bloco não estica string Focused() => settings.Items[settings.FocusIndex].Label; Assert.Equal(0, settings.FocusIndex); d.Press(InputAction.NavigateRight); - d.Press(InputAction.NavigateRight); // Detalhes (coluna 2) + d.Press(InputAction.NavigateRight); // Detalhes (coluna 3) d.Press(InputAction.NavigateDown); - Assert.StartsWith("Ordem:", Focused(), StringComparison.Ordinal); - d.Press(InputAction.NavigateDown); // última linha da grade: a lista do mesmo grupo - Assert.StartsWith("Tela cheia:", Focused(), StringComparison.Ordinal); // #230: linha da lista do grupo + Assert.StartsWith("Itens ocultos:", Focused(), StringComparison.Ordinal); // linha de baixo, mesma coluna + d.Press(InputAction.NavigateDown); // última linha da grade: a grade do grupo seguinte, mesma coluna + Assert.StartsWith("Restaurar abas ao abrir:", Focused(), StringComparison.Ordinal); d.Press(InputAction.NavigateUp); // volta à coluna de onde saiu - Assert.StartsWith("Ordem:", Focused(), StringComparison.Ordinal); + Assert.StartsWith("Itens ocultos:", Focused(), StringComparison.Ordinal); d.Press(InputAction.NavigateDown); - d.Press(InputAction.NavigateDown); // da lista para a grade do grupo seguinte: primeiro bloco - Assert.StartsWith("Busca em subpastas:", Focused(), StringComparison.Ordinal); app.TakeAnnouncement(); - d.Press(InputAction.NavigateRight); // Recentes: rótulo por extenso (com o valor) e a posição no bloco - Assert.Matches(@"^Recentes: (ligado|desligado), .*bloco 2 de 4$", app.TakeAnnouncement()); - d.Press(InputAction.NavigateUp); // primeira linha: o item de lista acima + d.Press(InputAction.NavigateRight); // Sugestões: rótulo por extenso (com o valor) e a posição no bloco + Assert.Matches(@"^Sugestões do teclado: (ligado|desligado), .*bloco 4 de 5$", app.TakeAnnouncement()); + d.Press(InputAction.NavigateUp); // da primeira linha da grade: a última linha da grade de cima, na coluna de onde saiu Assert.StartsWith("Tela cheia:", Focused(), StringComparison.Ordinal); - d.Press(InputAction.NavigateUp); // grade de cima, última linha, coluna 1 - Assert.StartsWith("Ordenar por:", Focused(), StringComparison.Ordinal); - d.Press(InputAction.NavigateRight); - d.Press(InputAction.NavigateRight); + d.Press(InputAction.NavigateLeft); var hidden = settings.FocusIndex; - Assert.Equal(("Ocultos", "escondidos"), (settings.Items[hidden].TileLabel, settings.Items[hidden].Value)); - // A linha sob a grade explica o ajuste em vez de repetir o valor que o bloco já mostra (auditoria de UX, #227). - Assert.StartsWith("Arquivos e pastas marcados como ocultos", settings.TileCaption, StringComparison.Ordinal); + Assert.StartsWith("Itens ocultos:", Focused(), StringComparison.Ordinal); + Assert.Equal("escondidos", settings.Items[hidden].Value); + // Focado, o bloco só de ícone revela o nome, o valor e o que faz, na leitura fixa do painel (nada de texto sempre visível). + Assert.StartsWith("Itens ocultos: escondidos\nArquivos e pastas marcados como ocultos", settings.TileCaption, StringComparison.Ordinal); + Assert.Equal(settings.TileCaption, settings.Description); d.Press(InputAction.Confirm); // alterna e continua no mesmo bloco, com o valor novo Assert.Same(settings, app.TopModal); @@ -323,11 +321,11 @@ public void Settings_grids_per_section_navigate_in_two_dimensions_with_the_lists app.PointerChooseModalOption(1); // toque numa alternativa do seletor: "compacta" Assert.Same(settings, app.TopModal); Assert.Equal(ListDensity.Compact, app.Settings.Density); - Assert.Equal(("Densidade", "compacta"), (settings.Items[1].TileLabel, settings.Items[1].Value)); + Assert.Equal(("Densidade da lista: compacta", "compacta"), (settings.Items[1].Label, settings.Items[1].Value)); }); [Fact] - public void Settings_is_the_first_tile_of_the_menu_and_its_groups_split_into_two_balanced_columns_with_bumper_jumps() => UiContext.Run(async () => + public void Settings_is_the_first_tile_of_the_menu_and_bumpers_jump_between_its_groups() => UiContext.Run(async () => { var d = Boot(); d.Press(InputAction.Confirm); @@ -339,12 +337,6 @@ public void Settings_is_the_first_tile_of_the_menu_and_its_groups_split_into_two d.Press(InputAction.Confirm); var settings = await d.WaitMenu(); - // #285: o corte em duas colunas cai entre grupos e deixa as duas metades parecidas. - var split = settings.ColumnSplit; - Assert.Contains(split, settings.SectionStarts); - Assert.InRange(split, 1, settings.Items.Count - 1); - Assert.NotEqual(settings.Items[split - 1].Section, settings.Items[split].Section); - // R1/L1 pulam de grupo em grupo (e dão a volta). var starts = settings.SectionStarts; Assert.True(starts.Count >= 3); @@ -369,12 +361,12 @@ public void Menu_size_class_and_reserved_descriptions_do_not_depend_on_the_focus d.Press(InputAction.OpenAppMenu); await d.ChooseMenu("Configurações"); var settings = (MenuModal)d.App.TopModal!; - Assert.Equal(ModalSize.Landscape, settings.Size); // #285: Configurações em paisagem + Assert.Equal(ModalSize.Wide, settings.Size); // #293: uma coluna larga var reserved = settings.AllDescriptions.ToList(); for (var i = 0; i < settings.Items.Count; i++) { settings.FocusIndex = i; - Assert.Equal(ModalSize.Landscape, settings.Size); + Assert.Equal(ModalSize.Wide, settings.Size); // Toda descrição que a tela pode mostrar tem o lugar reservado: focar a mais longa não redimensiona o painel (#227). if (settings.Description.Length > 0) Assert.Contains(settings.Description, reserved); }