Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,15 +129,18 @@ export default class EntryAbility extends UIAbility {
return;
}
const dark = colorMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
const background = dark ? MobileDesignColors.pageBg.dark : MobileDesignColors.pageBg.light;
const content = dark ? MobileDesignColors.ink.dark : MobileDesignColors.ink.light;
try {
this.mainWindow.setWindowSystemBarProperties({
// The status bar keeps the page colour it has always had — the window
// does not draw behind it on every device, and an opaque bar cannot
// regress the header band either way. The navigation bar is transparent
// instead: the chat page's bottom fade is what should be visible there.
statusBarColor: background,
// Both bars are transparent, because the window is laid out full-screen
// and the strips they occupy are the page's own edges: the drawer's fill
// and the chat page's blurred header band are what should show there.
// An opaque status bar painted the page colour over both, which clipped
// the sidebar's title, and it also sat over the pull-down notification
// centre's frosted background as a white strip instead of blending into
// it. `statusBarContentColor` is what keeps the clock and the icons
// legible against whatever the page puts behind them.
statusBarColor: MobileDesignColors.transparent.light,
navigationBarColor: MobileDesignColors.transparent.light,
statusBarContentColor: content,
navigationBarContentColor: content
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export struct AppSidebar {
* the screen edge for the compact drawer — and the session rows scroll under
* the navigation indicator, so the strip is the list's own business now. The
* floating footer keeps its distance from that edge itself, which is what
* keeps the buttons above the bar while the rows run under it.
* keeps the buttons above the bar while the rows run under it. The panel's top
* edge is the screen edge too, so the status bar strip is the panel's own
* padding rather than a band the framework reserved for it.
*/
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();
/**
Expand Down Expand Up @@ -156,7 +158,12 @@ export struct AppSidebar {
// the session rows are content that scrolls under the navigation indicator.
// The footer, its fade and the list's tail are what keep the strip clear,
// each against the edge this panel actually has.
.padding({ left: 20, right: 20, top: 0 })
//
// The panel's top edge is the screen edge in both hosts, so its own first
// row is what has to keep clear of the status bar: the fill still reaches
// the top of the window, and the title, the search field and the session
// rows start below the strip.
.padding({ left: 20, right: 20, top: this.insets.top })
.backgroundColor(SIDEBAR_BG)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,12 @@ export struct FilePreviewSurface {
})
}
.width('100%')
.height(68)
.padding({ left: 8, right: 8, top: 8, bottom: 8 })
.height(68 + this.insets.top)
// The header band is the surface's top edge in both hosts — the compact
// full-page overlay and the wide preview pane — so it carries the status bar
// strip itself: the band's fill still reaches the top of the window and the
// back control and title sit below the clock and the indicators.
.padding({ left: 8, right: 8, top: 8 + this.insets.top, bottom: 8 })
.alignItems(VerticalAlign.Center)
.border({ width: { bottom: 1 }, color: LINE })
.backgroundColor(PAGE_BG)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export struct MiniAppSurface {

build() {
Column() {
// The gallery is a full-screen surface, so its header band owns the top
// edge of the window: the box grows by the status bar strip and the
// controls keep the 56 the design gives them below it, instead of sitting
// under the clock and the indicators.
Row({ space: 8 }) {
NavigationBackButton({ onBack: (): void => {
if (this.state.appId) this.actions.back();
Expand All @@ -37,7 +41,7 @@ export struct MiniAppSurface {
.fontColor(INK).maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
.textAlign(TextAlign.Center).layoutWeight(1)
Blank().width(48)
}.width('100%').height(56).padding({ left: 12, right: 12 })
}.width('100%').height(56 + this.insets.top).padding({ left: 12, right: 12, top: this.insets.top })
if (this.state.document) {
MiniAppWebView({ document: this.state.document, revision: this.state.revision, request: this.actions.request })
.layoutWeight(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
} from './remote/RemoteSurfaceHost';
import { SidebarToggleButton } from './SidebarToggleButton';
import { SidebarWorkspaceSection } from './SidebarWorkspaceSection';
import { WindowInsetsBinding } from '../../services/WindowSystemBarService';
import {
LINE,
PAGE_BG,
Expand Down Expand Up @@ -63,6 +64,23 @@ export struct WideConversationHost {
@Event onRestoreMasterPane: () => void = () => {};
@Event onToggleFilePreviewExpanded: () => void = () => {};
@Event onOpenRemoteViewSettings: () => void = () => {};
/**
* The window's own insets, in vp. Every pane in this host is pinned to the
* window's edges, so the floating controls that are not part of a page's own
* header band keep the status bar strip clear themselves.
*/
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();

aboutToAppear(): void {
const context = this.getUIContext().getHostContext();
if (context) {
this.insets.bind(this.getUIContext(), context);
}
}

aboutToDisappear(): void {
this.insets.unbind();
}

build() {
if (this.showsRemoteConversation() &&
Expand Down Expand Up @@ -117,8 +135,10 @@ export struct WideConversationHost {
.width('100%').height('100%').justifyContent(FlexAlign.Start).backgroundColor(PAGE_BG)

if (this.wideMasterPaneCollapsed) {
// The detail pane's top edge is the window's, so the floating control
// keeps the status bar strip above it instead of sitting under it.
SidebarToggleButton({ restore: true, controlSize: 44, onToggle: this.onRestoreMasterPane })
.position({ x: 12, y: 12 }).zIndex(2)
.position({ x: 12, y: 12 + this.insets.top }).zIndex(2)
.hitTestBehavior(HitTestMode.Default)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RemoteSessionList } from '../RemoteSessionList';
import { RemoteSessionLoadingView } from '../RemoteSessionLoadingView';
import { SidebarToggleButton } from '../SidebarToggleButton';
import { SessionActionPresentation } from '../SessionActionSurface';
import { WindowInsetsBinding } from '../../../services/WindowSystemBarService';
import {
SettingsPlacement,
SettingsPlacementPolicy,
Expand Down Expand Up @@ -62,6 +63,13 @@ export struct RemoteSurfaceHost {
@Param wideMasterPaneCollapsed: boolean = false;
@Param sessionDetailsPlacement: SettingsPlacement =
SettingsPlacementPolicy.compactBottom(SettingsSheetKind.SessionDetails);
/**
* The window's own insets, in vp. The home surfaces are full-screen pages —
* the compact header and the wide placeholder are both the page's top edge —
* so their header bands reserve the status bar themselves rather than being
* handed a page that already ends below it.
*/
@Local insets: WindowInsetsBinding = new WindowInsetsBinding();
@Event onOpenSidebar: () => void = () => {};
@Event onRestoreSidebar: () => void = () => {};
@Event onCloseSettings: () => void = () => {};
Expand All @@ -71,11 +79,19 @@ export struct RemoteSurfaceHost {
// down for a change that is only page state — the sidebar rebuild this used
// to pay for. See AppShellViewModel.replaceRouteWithoutAnimation.
aboutToAppear(): void {
const context = this.getUIContext().getHostContext();
if (context) {
this.insets.bind(this.getUIContext(), context);
}
RemoteLogger.info(
`remote surface mounted mode=${this.mode} connection=${this.remotePageState.connectionState} sessions=${this.remotePageState.visibleSessions().length}`
);
}

aboutToDisappear(): void {
this.insets.unbind();
}

build() {
if (this.mode !== RemoteSurfaceMode.Settings &&
RemoteCompactHomePolicy.shouldShowWelcomeHome(this.remotePageState.controlTargetType,
Expand Down Expand Up @@ -219,13 +235,17 @@ export struct RemoteSurfaceHost {
@Builder
private CompactHomeContent() {
Column() {
// The compact home is a page, not a sheet: its header band is the top edge
// of the window, so the band reserves the status bar and the drawer
// control and title start below the clock and the indicators.
ConversationHeader({
title: RemoteI18n.t('app.title'),
subtitle: this.compactHeaderContext(),
titleFallback: RemoteI18n.t('app.title'),
showSidebarButton: true,
onOpenSidebar: this.onOpenSidebar
})
.padding({ top: this.insets.top })
if (RemoteCompactHomePolicy.shouldShowConnectHome(this.remotePageState.connectionState)) {
this.ConnectionEntry()
} else {
Expand Down Expand Up @@ -270,8 +290,11 @@ export struct RemoteSurfaceHost {
// No hairline under the welcome header. The compact header does not draw
// one and neither does the desktop client; the wide surface is the same
// page, not a sheet, so it should not grow a divider just because it is
// wider.
.width('100%').height(76).padding({ left: 16, right: 16, top: 14, bottom: 12 })
// wider. The band's box grows by the status bar strip for the same reason
// the compact header's does: the wide detail pane's top edge is the
// window's top edge too.
.width('100%').height(76 + this.insets.top)
.padding({ left: 16, right: 16, top: 14 + this.insets.top, bottom: 12 })

this.CompactEmptyState()

Expand Down
61 changes: 54 additions & 7 deletions src/apps/mobile/harmonyos/tools/tests/immersive-bottom.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const sessionActionSurface = source('entry/src/main/ets/pages/components/Session
const sessionDetailsView = source('entry/src/main/ets/pages/components/SessionDetailsView.ets');
const conversationViewSettings = source('entry/src/main/ets/pages/components/ConversationViewSettings.ets');
const welcomeHome = source('entry/src/main/ets/pages/components/WelcomeHome.ets');
const remoteSurfaceHost = source('entry/src/main/ets/pages/components/remote/RemoteSurfaceHost.ets');
const wideConversationHost = source('entry/src/main/ets/pages/components/WideConversationHost.ets');

// Reads one @Builder out of a component, so an assertion can name the layer it
// is about instead of counting matches in the whole file.
Expand Down Expand Up @@ -78,13 +80,19 @@ test('the shell runs the window full-screen for every page', () => {
'the welcome page must not switch the window layout any more');
});

test('the navigation bar is transparent and the status bar keeps the page colour', () => {
// The fade is what should be visible where the navigation bar sits, and the
// header band keeps its opaque page colour so the top does not regress.
test('both system bars are transparent so the page owns their strips', () => {
// The window runs full-screen, so the status bar's strip is the page's own
// top edge: the drawer's fill and the chat page's blurred header band are what
// should show there. An opaque bar painted the page colour over both — it
// clipped the sidebar's title — and it also sat over the pull-down
// notification centre's frosted background as a white strip. The content
// colour is what keeps the clock and the icons legible either way.
assert.match(entryAbility, /navigationBarColor: MobileDesignColors\.transparent\.light/,
'the navigation bar must be transparent so the page bottom shows through');
assert.match(entryAbility, /statusBarColor: background/,
'the status bar must keep the page colour');
assert.match(entryAbility, /statusBarColor: MobileDesignColors\.transparent\.light/,
'the status bar must be transparent so the page top shows through');
assert.match(entryAbility, /statusBarContentColor: content/,
'the status bar icons must keep following the colour mode');
});

test('the window inset service is the only place the strip is measured', () => {
Expand Down Expand Up @@ -131,6 +139,45 @@ test('the chat page header band reserves the status bar itself', () => {
'the band that carries the inset must stay the one that paints the header');
});

test('every surface whose top edge is the screen edge reserves the status bar', () => {
// Immersive layout moved every page's origin to the top of the window, so the
// first row of a full-screen surface is what has to keep clear of the status
// bar. The band that owns the surface's fill carries the padding, which is
// what keeps that fill reaching the top of the window while the controls start
// below the clock and the indicators: the drawer panel's title, the gallery's
// back control, the preview's header and the home headers' drawer control are
// all one strip lower than they were while the page area ended below it.
assert.match(normalize(builderBody(appSidebar, 'SidebarContent')),
/\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/,
'the drawer panel must reserve the strip above its title');
assert.match(normalize(miniAppSurface),
/\.height\(56 \+ this\.insets\.top\)\.padding\(\{ left: 12, right: 12, top: this\.insets\.top \}\)/,
'the mini-app gallery header must reserve the strip');
assert.match(normalize(filePreviewSurface),
/\.height\(68 \+ this\.insets\.top\)[\s\S]*?\.padding\(\{ left: 8, right: 8, top: 8 \+ this\.insets\.top, bottom: 8 \}\)/,
'the file preview header must reserve the strip');
assert.match(normalize(remoteSurfaceHost),
/\.padding\(\{ top: this\.insets\.top \}\)/,
'the compact home header must reserve the strip');
assert.match(normalize(remoteSurfaceHost),
/\.height\(76 \+ this\.insets\.top\) \.padding\(\{ left: 16, right: 16, top: 14 \+ this\.insets\.top, bottom: 12 \}\)/,
'the wide home header must reserve the strip');
// A floating control on a pane whose top edge is the window's keeps the strip
// clear itself, the way the surfaces' fixed bottom controls keep the
// navigation bar clear.
assert.match(normalize(wideConversationHost),
/\.position\(\{ x: 12, y: 12 \+ this\.insets\.top \}\)/,
'the floating master-restore control must reserve the strip');
for (const [name, text] of [['RemoteSurfaceHost', remoteSurfaceHost],
['WideConversationHost', wideConversationHost]]) {
assert.match(text, /@Local insets: WindowInsetsBinding = new WindowInsetsBinding\(\);/,
`${name} must hold the shared inset binding`);
assert.match(text, /this\.insets\.bind\(this\.getUIContext\(\), context\)/,
`${name} must bind the insets while it is mounted`);
assert.match(text, /this\.insets\.unbind\(\)/, `${name} must release the insets when it goes`);
}
});

test('the settings sheet viewport reaches the screen edge and its rows end in a tail spacer', () => {
// The sheet is a bindSheet, which the framework does not inset while the
// window is immersive, so its own box decides where content can scroll. The
Expand Down Expand Up @@ -171,8 +218,8 @@ test('the sidebar list scrolls under the bar and its floating footer keeps the s
// the footer keeps its distance from that edge itself, and the list ends in
// a tail spacer so its last row rests above the navigation bar.
const content = normalize(builderBody(appSidebar, 'SidebarContent'));
assert.match(content, /\.padding\(\{ left: 20, right: 20, top: 0 \}\)/,
'the panel root must not shrink its scrolling viewport');
assert.match(content, /\.padding\(\{ left: 20, right: 20, top: this\.insets\.top \}\)/,
'the panel root must reserve the status bar without shrinking its scrolling viewport');
assert.match(content, /\.padding\(\{ bottom: this\.scrollTailPadding\(\) \}\)/,
'the session list must end in a tail spacer');
assert.match(content, /\.margin\(\{ bottom: this\.footerBottomPadding\(\) \}\)/,
Expand Down
Loading