diff --git a/lib/src/components/tab_bar.dart b/lib/src/components/tab_bar.dart index 6cf603f..33c06a2 100644 --- a/lib/src/components/tab_bar.dart +++ b/lib/src/components/tab_bar.dart @@ -85,9 +85,30 @@ class TabBar extends StatelessComponent { ), ); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: tabComponents, + return Stack( + children: [ + // A full-width base rule painted behind the tab segments, in the + // border's color like the segments themselves. It fills the cells + // fractional pane widths leave uncovered (the segments size with + // maxWidth.toInt(), truncating half cells), and its ends reach one + // cell outside the bar to merge the underline into a surrounding + // box border (┝/┥). Ends with no border to join paint nothing. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Divider( + style: DividerStyle.bold, + indent: -1, + endIndent: -1, + color: ServerpodTheme.of(context).subtleDivider, + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: tabComponents, + ), + ], ); } } @@ -136,9 +157,10 @@ class _Tab extends StatelessComponent { ), Text( ''.padLeft(underlineWidth, '━'), + // The selection highlight keeps its accent color; every other + // underline segment matches the surrounding box border. style: TextStyle( - color: selected ? theme.activationKey : null, - fontWeight: selected ? FontWeight.normal : FontWeight.dim, + color: selected ? theme.activationKey : theme.subtleDivider, ), ), ], @@ -228,7 +250,12 @@ class _TabSpacing extends StatelessComponent { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(''.padLeft(width)), - Text(underline, style: const TextStyle(fontWeight: FontWeight.dim)), + // The underline shares the surrounding box border's color and + // weight so bar and border read as one frame. + Text( + underline, + style: TextStyle(color: ServerpodTheme.of(context).subtleDivider), + ), ], ); } diff --git a/test/src/components/tab_bar_test.dart b/test/src/components/tab_bar_test.dart index 5657114..ab3f317 100644 --- a/test/src/components/tab_bar_test.dart +++ b/test/src/components/tab_bar_test.dart @@ -79,6 +79,119 @@ void main() { }); }); + test( + 'Given a tab bar laid out at a fractional width ' + 'when rendered inside a bordered box ' + 'then the underline has no gap before the border junction', + () async { + tester = await NoctermTester.create(size: const Size(40, 6)); + await tester.pumpComponent( + Container( + decoration: BoxDecoration( + border: BoxBorder.all(style: BoxBorderStyle.rounded), + ), + child: Row( + children: [ + // Forces the bar onto fractional cells: its segments size + // with maxWidth.toInt(), which would leave the final half + // cell unpainted without the base rule behind them. + const SizedBox(width: 1.5), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + labels: const ['Server logs'], + selectedTab: 0, + onTabChanged: (_) {}, + ), + Expanded(child: const SizedBox.shrink()), + ], + ), + ), + ], + ), + ), + ); + + final ts = tester.terminalState; + // The cell beside the border keeps the underline; a truncation gap + // here would break the line just before its junction. + expect(ts.getTextAt(38, 2, length: 1), '━'); + expect(ts.getTextAt(39, 2, length: 1), '┥'); + }, + ); + + test( + 'Given a tab bar inside a bordered box ' + 'when rendered then the underline merges into both border sides', + () async { + tester = await NoctermTester.create(size: const Size(40, 6)); + await tester.pumpComponent( + Container( + decoration: BoxDecoration( + border: BoxBorder.all(style: BoxBorderStyle.rounded), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + labels: const ['Server logs'], + selectedTab: 0, + onTabChanged: (_) {}, + ), + Expanded(child: const SizedBox.shrink()), + ], + ), + ), + ); + + // The tab bar sits inside the border, so its label is on row 1 and + // its underline on row 2. The underline's ends reach the border + // cells and form heavy tees instead of leaving gaps. + final ts = tester.terminalState; + expect(ts.getTextAt(0, 2, length: 1), '┝'); + expect(ts.getTextAt(39, 2, length: 1), '┥'); + // The whole underline - junctions and segments alike - carries the + // surrounding box border's color, so bar and border read as one + // frame. + final borderColor = ServerpodThemeData.dark.subtleDivider; + expect(ts.getCellAt(0, 2)?.style.color, borderColor); + expect(ts.getCellAt(20, 2)?.style.color, borderColor); + }, + ); + + test( + 'Given a tab bar with no surrounding border ' + 'when rendered ' + 'then the underline ends paint no stray junction arms', + () async { + tester = await NoctermTester.create(size: const Size(40, 4)); + await tester.pumpComponent( + Container( + padding: EdgeInsets.all(1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + labels: const ['Server logs'], + selectedTab: 0, + onTabChanged: (_) {}, + ), + Expanded(child: const SizedBox.shrink()), + ], + ), + ), + ); + + // The base rule reaches one cell outside the tab bar on each side, + // but with nothing there to join it leaves those cells untouched. + final ts = tester.terminalState; + expect(ts.getTextAt(0, 2, length: 1), ' '); + expect(ts.getTextAt(39, 2, length: 1), ' '); + }, + ); + test( 'Given fewer states than labels ' 'when rendered '