From 3cfdf4a618d9b749f32950f391d79f8e6ce97eb3 Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:49:28 -0300 Subject: [PATCH 1/2] feat: landscape Settings in two columns, L1/R1 group jumps, Settings first in the Menu (#285, #288) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 1 + CHANGELOG.md | 1 + docs/ui-redesign.md | 2 +- src/ControlFS.App/Views/ModalView.cs | 36 ++++++++++-- .../AppController.Browser.cs | 2 +- src/ControlFS.Application/AppController.cs | 6 ++ src/ControlFS.Application/State/Modals.cs | 57 ++++++++++++++++++- .../Application/ModalSystemJourneyTests.cs | 38 ++++++++++++- 8 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index e2811f9..2163f2d 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -6,6 +6,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### Fixes - **Xbox controllers no longer ask to be configured** (#284): when an Xbox controller also showed up as a "joystick without a profile" (a second instance, a receiver or a generic driver), the app said "Controle sem perfil… hold any button" and opened the mapping wizard even though the controller already worked. Xbox controllers (Microsoft vendor, or "Xbox"/"XInput" in the name) now never trigger the notice or the wizard; real generic joysticks can still be configured. ### Changes +- **Landscape Settings, easier to find** (#285, #288, #227): Configurações now uses the screen's width: groups sit side by side in two columns (Exibição and Busca on the left; Controles and ControlFS on the right, in reading order: Down at the end of the first column continues at the top of the second), with the description below and no scrolling at 720p. In a narrow window it goes back to one column. **L1/R1** jump between groups. In the Menu, **Configurações** is now the **first tile** (already focused: Menu + Confirm opens it), with the gear and what's inside. No setting was removed or changed behavior. - **Controller sounds on by default and nicer** (#286, #287): a fresh install (or no saved choice) now has the sounds on at medium volume; anyone who picked "desligados" (off) stays silent, and the choice is kept across restarts and updates. The four cues were redone: glassy timbre (a fundamental with soft harmonics), rounded attack, gentle decay, all at the same loudness and without clicks; "baixo" (low) is now really low. They are still synthesized in the app (no third-party files; origin and license in THIRD_PARTY_NOTICES.md). - **Git status left Configurações** (#284): the option, the "Status do Git: ligado/desligado" notice, the branch in the header and the "Git: modificado" marks are gone from the interface. ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db11a2..964aeed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### Correções - **Controle Xbox não pede mais para ser configurado** (#284): quando um controle Xbox aparecia também como "joystick sem perfil" (segunda instância, receptor ou driver genérico), o app avisava "Controle sem perfil… segure qualquer botão" e abria o assistente de configuração, mesmo com o controle já funcionando. Agora controles Xbox (fabricante Microsoft, ou "Xbox"/"XInput" no nome) nunca disparam o aviso nem o assistente; joysticks genéricos de verdade continuam podendo ser configurados. ### Mudanças +- **Configurações em paisagem e mais fáceis de achar** (#285, #288, #227): as Configurações agora ocupam a largura da tela: os grupos ficam lado a lado em duas colunas (Exibição e Busca à esquerda; Controles e ControlFS à direita, em ordem de leitura: Baixo no fim da primeira coluna continua no topo da segunda), com descrição embaixo, sem rolar em 720p. Em janela estreita voltam a uma coluna. **L1/R1** pulam de um grupo para outro. No Menu, **Configurações** agora é o **primeiro bloco** (já em foco: Menu + Confirmar abre), com a engrenagem e o que tem dentro. Nenhum ajuste foi removido ou mudou de comportamento. - **Sons do controle ligados por padrão e mais bonitos** (#286, #287): numa instalação nova (ou sem escolha salva) os sons já vêm ligados em volume médio; quem escolheu "desligados" continua sem som, e a escolha fica salva ao reabrir e ao atualizar. Os quatro sons foram refeitos: timbre de vidro (fundamental com harmônicos suaves), ataque arredondado, queda suave, todos com o mesmo volume e sem estalos; o volume "baixo" agora é realmente baixo. Continuam sintetizados no app (sem arquivos de terceiros; a origem e a licença estão em THIRD_PARTY_NOTICES.md). - **Status do Git saiu das Configurações** (#284): a opção, o aviso "Status do Git: ligado/desligado", o ramo no cabeçalho e as marcas "Git: modificado" deixaram de existir na interface. ### Novidades diff --git a/docs/ui-redesign.md b/docs/ui-redesign.md index a5897f3..77e7081 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 | 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 (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) | | 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 a57cffc..9902b93 100644 --- a/src/ControlFS.App/Views/ModalView.cs +++ b/src/ControlFS.App/Views/ModalView.cs @@ -161,6 +161,9 @@ 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 (). @@ -171,6 +174,7 @@ public static void ScrollBody(int steps) ModalSize.Medium => 540, ModalSize.Standard => 640, ModalSize.Wide => 960, + ModalSize.Landscape => 1080, _ => double.PositiveInfinity, }; @@ -607,6 +611,13 @@ 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). @@ -616,18 +627,20 @@ 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) stack.Children.Add(SectionHeading(titles || (item.Section is not null && item.Section == menu.TitledSection) ? item.Section : null, first: i == 0)); + if (heading) target.Children.Add(SectionHeading(titles || (item.Section is not null && item.Section == menu.TitledSection) ? item.Section : null, first: target.Children.Count == 0)); section = item.Section; if (grid is null) { - positions[i] = stack.Children.Count; - stack.Children.Add(MenuRow(app, menu, i)); + positions[i] = target.Children.Count; + owners[i] = target; + target.Children.Add(MenuRow(app, menu, i)); i++; continue; } @@ -635,10 +648,21 @@ 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)); - stack.Children.Add(tiles); + target.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 }); @@ -661,13 +685,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), stack, menu.Size, footerSink: f => footer = f, compact: true, below: below); + var card = Panel(app, Header(menu, compact: true), body, 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 stack.Children[positions[index]] = MenuRow(app, menu, index); + else owners[index].Children[positions[index]] = MenuRow(app, menu, index); } update = () => { diff --git a/src/ControlFS.Application/AppController.Browser.cs b/src/ControlFS.Application/AppController.Browser.cs index 9ff80e2..63014f2 100644 --- a/src/ControlFS.Application/AppController.Browser.cs +++ b/src/ControlFS.Application/AppController.Browser.cs @@ -575,6 +575,7 @@ private void ShowAppMenu() const MenuPlacement quick = MenuPlacement.Quick; List items = [ + new("Configurações…", ShowSettings, Detail: "Tema, exibição, sons, controles, privacidade e atualizações. Sempre o primeiro bloco do Menu.", Icon: ActionIcon.Settings, Placement: quick), new(PasteLabel, () => Paste(pane), inBrowser ? PasteUnavailable(pane) : "Abra uma pasta do disco para colar.", Icon: ActionIcon.Paste, Placement: quick, ShortLabel: "Colar"), new("Nova pasta", () => BeginCreateFolder(pane), inBrowser && pane.Location is PhysicalLocation ? null : "Abra uma pasta do disco primeiro.", Icon: ActionIcon.NewFolder, Placement: quick), new("Nova aba", NewTabHere, NewTabUnavailable, Detail: "Abre a pasta atual numa aba nova. Com 2+ abas, L2/R2 trocam de aba.", Icon: ActionIcon.NewTab, Placement: quick), @@ -582,7 +583,6 @@ private void ShowAppMenu() new("Ir para caminho…", () => BeginGoToPath(pane), Detail: "Digite ou cole o caminho de uma pasta (ex.: D:\\Jogos).", Icon: ActionIcon.GoToPath, Placement: quick, ShortLabel: "Caminho"), new($"Operações ({Plural.Of(Operations.ActiveCount, "ativa", "ativas")})", ShowOperations, Operations.Items.Count == 0 && History.Entries.Count == 0 ? "Nenhuma operação registrada." : null, Icon: ActionIcon.Operations, Placement: quick, ShortLabel: "Operações"), - new("Configurações…", ShowSettings, Detail: "Exibição, busca, privacidade, controles e atualizações.", Icon: ActionIcon.Settings, Placement: quick), new("Ir para o início", GoHome, Screen == Screen.Home ? "Você já está no início." : null, Icon: ActionIcon.Home, Placement: quick, ShortLabel: "Início"), FullScreenTile(), new("Ir para pasta acima…", () => ShowPathMenu(pane), inBrowser && BuildBreadcrumbs(pane).Count > 1 ? null : "Não há pastas acima desta.", diff --git a/src/ControlFS.Application/AppController.cs b/src/ControlFS.Application/AppController.cs index ab409ec..f2c74f6 100644 --- a/src/ControlFS.Application/AppController.cs +++ b/src/ControlFS.Application/AppController.cs @@ -472,6 +472,12 @@ private void HandleMenu(MenuModal menu, InputAction action) break; case InputAction.PageUp: menu.FocusIndex = 0; break; case InputAction.PageDown: menu.FocusIndex = count - 1; break; + case InputAction.NextRegion or InputAction.PreviousRegion when menu.SectionStarts is { Count: > 1 } starts: + // Configurações (#285): R1/L1 pulam para o primeiro ajuste do grupo seguinte/anterior (dão a volta). + var current = starts.ToList().FindLastIndex(s => s <= menu.FocusIndex); + var step = action == InputAction.NextRegion ? 1 : -1; + menu.FocusIndex = starts[((current + step) % starts.Count + starts.Count) % starts.Count]; + break; case InputAction.Confirm: case InputAction.NavigateRight when !menu.IsQuick(menu.FocusIndex): ChooseMenuItem(menu); diff --git a/src/ControlFS.Application/State/Modals.cs b/src/ControlFS.Application/State/Modals.cs index 8297f2d..8065412 100644 --- a/src/ControlFS.Application/State/Modals.cs +++ b/src/ControlFS.Application/State/Modals.cs @@ -22,6 +22,12 @@ 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, } @@ -140,6 +146,55 @@ private int InitialFocus(MenuItem first) public IReadOnlyList Items { get; private set; } = []; public int FocusIndex { get; internal set; } + /// + /// 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. + /// + public IReadOnlyList SectionStarts + { + get + { + if (!_sectionGrids) return []; + var starts = new List(); + for (var i = 0; i < Items.Count; i++) + if (i == 0 || Items[i].Section != Items[i - 1].Section) starts.Add(i); + return starts; + } + } + + /// + /// Í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; } = []; @@ -200,7 +255,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 => Grids.Count > 0 || IsPicker ? ModalSize.Medium : ModalSize.Compact; + public override ModalSize Size => _sectionGrids ? ModalSize.Landscape : Grids.Count > 0 || IsPicker ? ModalSize.Medium : ModalSize.Compact; /// /// Seletor de opções (#261): cada item é uma alternativa de um valor; é a atual (marcada com diff --git a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs index 91c8831..5dcaacd 100644 --- a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs @@ -326,6 +326,40 @@ public void Settings_grids_per_section_navigate_in_two_dimensions_with_the_lists Assert.Equal(("Densidade", "compacta"), (settings.Items[1].TileLabel, 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 () => + { + var d = Boot(); + d.Press(InputAction.Confirm); + await d.Idle(); + d.Press(InputAction.OpenAppMenu); + var menu = await d.WaitMenu(); + Assert.StartsWith("Configurações", menu.Items[0].Label, StringComparison.Ordinal); // #288: primeiro bloco do Menu + Assert.Equal(0, menu.FocusIndex); // e já em foco: Menu + Confirmar abre + 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); + Assert.Equal(0, settings.FocusIndex); + d.Press(InputAction.NextRegion); + Assert.Equal(starts[1], settings.FocusIndex); + d.Press(InputAction.NextRegion); + Assert.Equal(starts[2], settings.FocusIndex); + d.Press(InputAction.PreviousRegion); + Assert.Equal(starts[1], settings.FocusIndex); + d.Press(InputAction.PreviousRegion); + d.Press(InputAction.PreviousRegion); + Assert.Equal(starts[^1], settings.FocusIndex); // volta ao último grupo + }); + [Fact] public void Menu_size_class_and_reserved_descriptions_do_not_depend_on_the_focused_option() => UiContext.Run(async () => { @@ -335,12 +369,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.Medium, settings.Size); + Assert.Equal(ModalSize.Landscape, settings.Size); // #285: Configurações em paisagem var reserved = settings.AllDescriptions.ToList(); for (var i = 0; i < settings.Items.Count; i++) { settings.FocusIndex = i; - Assert.Equal(ModalSize.Medium, settings.Size); + Assert.Equal(ModalSize.Landscape, 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); } From 7daf64eee7c1445cc0823d60318eb87ad3191d3c Mon Sep 17 00:00:00 2001 From: nextestudios <47460003+nextestudios@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:51:43 -0300 Subject: [PATCH 2/2] test: Paste is the second tile now that Settings is first Co-Authored-By: Claude Sonnet 5.5 --- tests/ControlFS.UnitTests/Application/HintJourneyTests.cs | 4 ++-- .../Application/ModalSystemJourneyTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ControlFS.UnitTests/Application/HintJourneyTests.cs b/tests/ControlFS.UnitTests/Application/HintJourneyTests.cs index 70aef93..96dec9b 100644 --- a/tests/ControlFS.UnitTests/Application/HintJourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/HintJourneyTests.cs @@ -96,10 +96,10 @@ public void Dialogs_and_menus_name_the_focused_choice_and_hide_confirm_on_unavai app.Start(); var d = new Driver(app); - d.Press(InputAction.OpenAppMenu); // na tela inicial, o primeiro item (Colar) está indisponível + d.Press(InputAction.OpenAppMenu); // na tela inicial, Colar (o segundo bloco; Configurações é o primeiro) está indisponível var menu = await d.WaitMenu(); Assert.True(menu.Items[menu.FocusIndex].IsEnabled); // o menu nunca abre numa opção indisponível - d.Press(InputAction.PageUp); // leva o foco até Colar + d.Press(InputAction.NavigateRight); // leva o foco até Colar Assert.False(menu.Items[menu.FocusIndex].IsEnabled); Assert.Null(Label(app, InputAction.Confirm)); while (!menu.Items[menu.FocusIndex].IsEnabled) d.Press(InputAction.NavigateDown); diff --git a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs index 5dcaacd..d0fed15 100644 --- a/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs +++ b/tests/ControlFS.UnitTests/Application/ModalSystemJourneyTests.cs @@ -390,9 +390,9 @@ public void Menus_never_open_focused_on_an_unavailable_option() => UiContext.Run var d = Boot(); d.Press(InputAction.Confirm); await d.Idle(); - d.Press(InputAction.OpenAppMenu); // nada copiado: "Colar", o primeiro bloco, está indisponível + d.Press(InputAction.OpenAppMenu); // nada copiado: "Colar" está indisponível (Configurações é o primeiro bloco, #288) var menu = Assert.IsType(d.App.TopModal); - Assert.False(menu.Items[0].IsEnabled); + Assert.False(menu.Items.Single(i => i.Label.StartsWith("Colar", StringComparison.Ordinal)).IsEnabled); Assert.True(menu.Items[menu.FocusIndex].IsEnabled); Assert.False(menu.Items[menu.FocusIndex].IsDestructive); });