瀏覽代碼

Fit the window and layout to the display

Size the window to 16:9 at 90% of the screen's work area (at most
1280x720, at least 960x540) and make the layout fill the window height:
the grids shrink to fit instead of using square cells, and the title and
project rows merge into one bar. Nothing scrolls at the page level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude 1 天之前
父節點
當前提交
fbd8cc2a1b

+ 44 - 28
frontend/src/App.svelte

@@ -72,12 +72,8 @@
 </script>
 
 <div class="page">
-  <div class="titlebar">
-    <h1>mpckit</h1>
-    <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
-  </div>
-
   <header>
+    <h1>mpckit</h1>
     <div class="name">
       <label class="eyebrow" for="project-name">Project name</label>
       <input id="project-name" type="text" value={st?.name ?? ""} readonly />
@@ -88,6 +84,7 @@
       <button type="button" class="btn" disabled>Export zip</button>
       <button type="button" class="btn primary" disabled>Export to folder</button>
     </div>
+    <button type="button" class="about-link" onclick={() => (ui.aboutOpen = true)}>About</button>
   </header>
 
   {#if app.error}
@@ -117,7 +114,7 @@
             }}
           />
           <SlotGrid cells={padCells} selected={ui.padSlot} onpick={(s) => (ui.padSlot = s)} />
-          <p class="hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
+          <p class="hint grid-hint">Click a pad to select and hear it. Drag a pad to move it, drag onto a filled pad to swap, hold Alt while dragging to copy. Drop a WAV file on any pad to assign it.</p>
         </section>
         <PadPanel pad={st.pads[ui.padSlot]} limits={st.limits} />
       </main>
@@ -138,7 +135,7 @@
             }}
           />
           <SlotGrid cells={seqCells} selected={ui.seqSlot} onpick={(s) => (ui.seqSlot = s)} />
-          <p class="hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
+          <p class="hint grid-hint">Each pad is one sequence slot, {st.limits.maxSequences} in total. Click an empty slot to add a sequence there.</p>
         </section>
         <SequencePanel slot={ui.seqSlot} seq={seqBySlot.get(ui.seqSlot)} limits={st.limits} />
       </main>
@@ -159,21 +156,16 @@
 </div>
 
 <style>
+  /* Exactly the window's height: header, tabs, panels (which take the rest)
+     and footer. Nothing scrolls at the page level. */
   .page {
-    max-width: 1280px;
+    height: 100%;
+    max-width: 1440px;
     margin: 0 auto;
-    padding: 24px;
+    padding: 12px 20px;
     display: flex;
     flex-direction: column;
-    gap: 20px;
-  }
-  .titlebar {
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    gap: 16px;
-    padding-bottom: 12px;
-    border-bottom: 1px solid var(--line);
+    gap: 12px;
   }
   h1 {
     margin: 0;
@@ -183,7 +175,7 @@
     letter-spacing: 0.02em;
   }
   .about-link {
-    min-height: 44px;
+    min-height: 40px;
     padding: 0 8px;
     background: transparent;
     color: var(--accent);
@@ -195,21 +187,27 @@
     cursor: pointer;
   }
   header {
+    flex: none;
     display: flex;
-    flex-wrap: wrap;
-    align-items: flex-end;
+    align-items: center;
     gap: 16px;
+    padding-bottom: 12px;
+    border-bottom: 1px solid var(--line);
   }
   .name {
     display: flex;
-    flex-direction: column;
-    gap: 6px;
-    flex: 1 1 240px;
+    align-items: center;
+    gap: 10px;
+    flex: 1 1 auto;
     min-width: 0;
-    max-width: 360px;
+    max-width: 380px;
+  }
+  .name label {
+    white-space: nowrap;
   }
   .name input {
-    height: 44px;
+    height: 40px;
+    min-width: 120px;
     padding: 0 12px;
     background: var(--surface);
     color: var(--text);
@@ -221,17 +219,17 @@
   }
   .actions {
     display: flex;
-    flex-wrap: wrap;
     gap: 8px;
     margin-left: auto;
   }
   .tabs {
+    flex: none;
     display: flex;
     gap: 4px;
     border-bottom: 1px solid var(--line);
   }
   .tabs button {
-    height: 48px;
+    height: 40px;
     padding: 0 22px;
     background: transparent;
     color: var(--muted);
@@ -257,13 +255,31 @@
     color: var(--muted);
   }
   footer {
+    flex: none;
     display: flex;
     flex-wrap: wrap;
     gap: 8px 24px;
     font-size: 12px;
     color: var(--muted);
   }
+  /* Narrow window: drop the visible name label (the input keeps it). */
+  @media (max-width: 1100px) {
+    .name label {
+      position: absolute;
+      width: 1px;
+      height: 1px;
+      overflow: hidden;
+      clip-path: inset(50%);
+    }
+  }
+  /* Short window: give the grid the hint's lines. */
+  @media (max-height: 600px) {
+    .grid-hint {
+      display: none;
+    }
+  }
   .error {
+    flex: none;
     margin: 0;
     padding: 10px 12px;
     border: 1px solid #e5484d;

+ 27 - 12
frontend/src/app.css

@@ -25,9 +25,18 @@
   box-sizing: border-box;
 }
 
+/* The app always fills the window exactly and never scrolls as a page;
+   the grids shrink to the height that is left (see App.svelte). */
+html,
+body,
+#app {
+  height: 100%;
+}
+
 html,
 body {
   margin: 0;
+  overflow: hidden;
   background: var(--bg);
   color: var(--text);
   font-family: var(--sans);
@@ -70,7 +79,7 @@ a:hover {
 
 /* Buttons. */
 .btn {
-  height: 44px;
+  height: 40px;
   padding: 0 16px;
   background: var(--surface);
   color: var(--text);
@@ -101,7 +110,7 @@ select:disabled {
 
 /* Text, number and select fields. */
 .field {
-  height: 44px;
+  height: 40px;
   padding: 0 10px;
   width: 100%;
   background: var(--bg);
@@ -116,13 +125,15 @@ select:disabled {
 
 /* The two side-by-side panels of each tab. */
 .panels {
+  flex: 1;
+  min-height: 0;
   display: flex;
-  flex-wrap: wrap;
-  gap: 20px;
-  align-items: flex-start;
+  gap: 16px;
+  align-items: stretch;
 }
 .panel {
   min-width: 0;
+  min-height: 0;
   background: var(--surface);
   border: 1px solid var(--line);
   border-radius: 10px;
@@ -132,11 +143,15 @@ select:disabled {
   gap: 16px;
 }
 .panel.grid-panel {
-  flex: 1.4 1 380px;
-  gap: 14px;
+  flex: 1.4 1 0;
+  gap: 10px;
+  padding: 14px;
 }
 .panel.settings {
-  flex: 1 1 320px;
+  flex: 1 1 0;
+  gap: 12px;
+  padding: 14px;
+  overflow-y: auto; /* only when the window is very short */
 }
 
 .panel-head {
@@ -146,7 +161,7 @@ select:disabled {
 }
 .panel-head .slot-id {
   font-family: var(--display);
-  font-size: 28px;
+  font-size: 26px;
   font-weight: 700;
   color: var(--accent);
 }
@@ -165,7 +180,7 @@ select:disabled {
   display: flex;
   flex-direction: column;
   gap: 12px;
-  padding: 20px;
+  padding: 16px;
   border: 1px dashed var(--edge);
   border-radius: 8px;
 }
@@ -180,7 +195,7 @@ select:disabled {
 .stack {
   display: flex;
   flex-direction: column;
-  gap: 16px;
+  gap: 12px;
 }
 .row {
   display: flex;
@@ -213,7 +228,7 @@ select:disabled {
 }
 .control input[type="range"] {
   width: 100%;
-  height: 44px;
+  height: 28px;
   margin: 0;
   accent-color: var(--accent);
   cursor: pointer;

+ 1 - 1
frontend/src/lib/BankBar.svelte

@@ -33,7 +33,7 @@
     gap: 6px;
   }
   button {
-    height: 44px;
+    height: 36px;
     padding: 0;
     background: var(--raised);
     color: var(--text);

+ 25 - 17
frontend/src/lib/PadPanel.svelte

@@ -36,24 +36,26 @@
 
       <div class="control-group stack">
         <h3 class="eyebrow">Tuning</h3>
-        <div class="control">
-          <div class="top">
-            <label class="label" for="tune-coarse">Semitones</label>
-            <span class="value">{signed(pad.coarse)} st</span>
+        <div class="pair">
+          <div class="control">
+            <div class="top">
+              <label class="label" for="tune-coarse">Semitones</label>
+              <span class="value">{signed(pad.coarse)} st</span>
+            </div>
+            <input id="tune-coarse" type="range" min={limits.minCoarse} max={limits.maxCoarse} step="1" value={pad.coarse} disabled />
+            <div class="scale" aria-hidden="true">
+              <span>{limits.minCoarse}</span><span>0</span><span>+{limits.maxCoarse}</span>
+            </div>
           </div>
-          <input id="tune-coarse" type="range" min={limits.minCoarse} max={limits.maxCoarse} step="1" value={pad.coarse} disabled />
-          <div class="scale" aria-hidden="true">
-            <span>{limits.minCoarse}</span><span>0</span><span>+{limits.maxCoarse}</span>
-          </div>
-        </div>
-        <div class="control">
-          <div class="top">
-            <label class="label" for="tune-fine">Cents</label>
-            <span class="value">{signed(pad.fine)} ct</span>
-          </div>
-          <input id="tune-fine" type="range" min={limits.minFine} max={limits.maxFine} step="1" value={pad.fine} disabled />
-          <div class="scale" aria-hidden="true">
-            <span>{limits.minFine}</span><span>0</span><span>+{limits.maxFine}</span>
+          <div class="control">
+            <div class="top">
+              <label class="label" for="tune-fine">Cents</label>
+              <span class="value">{signed(pad.fine)} ct</span>
+            </div>
+            <input id="tune-fine" type="range" min={limits.minFine} max={limits.maxFine} step="1" value={pad.fine} disabled />
+            <div class="scale" aria-hidden="true">
+              <span>{limits.minFine}</span><span>0</span><span>+{limits.maxFine}</span>
+            </div>
           </div>
         </div>
       </div>
@@ -82,4 +84,10 @@
   .control-group {
     gap: 8px;
   }
+  /* Semitones and cents side by side, to keep the panel short. */
+  .pair {
+    display: grid;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: 16px;
+  }
 </style>

+ 11 - 6
frontend/src/lib/SlotGrid.svelte

@@ -39,15 +39,18 @@
 </div>
 
 <style>
+  /* Fills the height its panel gives it: four equal rows. */
   .grid {
+    flex: 1;
+    min-height: 0;
     display: grid;
     grid-template-columns: repeat(4, minmax(0, 1fr));
-    gap: 10px;
+    grid-template-rows: repeat(4, minmax(0, 1fr));
+    gap: 8px;
   }
   button {
-    aspect-ratio: 1 / 1;
-    min-height: 72px;
-    padding: 8px;
+    min-height: 0;
+    padding: 6px 8px;
     display: flex;
     flex-direction: column;
     align-items: flex-start;
@@ -82,14 +85,16 @@
     font-size: 12px;
     font-weight: 600;
     line-height: 1.25;
-    overflow: hidden;
     max-width: 100%;
-    overflow-wrap: anywhere;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
   .meta {
     font-family: var(--mono);
     font-size: 11px;
     color: var(--soft);
     min-height: 15px;
+    white-space: nowrap;
   }
 </style>

+ 38 - 5
main.go

@@ -6,6 +6,7 @@ import (
 	"log"
 
 	"github.com/wailsapp/wails/v3/pkg/application"
+	"github.com/wailsapp/wails/v3/pkg/events"
 
 	"git.monoexp.com/bajdev/mpckit/internal/app"
 )
@@ -15,6 +16,13 @@ import (
 //go:embed all:frontend/dist
 var assets embed.FS
 
+// Window sizes are in device-independent pixels, 16:9 like most displays.
+const (
+	maxWidth, maxHeight = 1280, 720 // largest default size
+	minWidth, minHeight = 960, 540  // smallest the layout is designed for
+	screenShare         = 0.9       // default size: this share of the screen's work area
+)
+
 func main() {
 	a := application.New(application.Options{
 		Name:        "mpckit",
@@ -30,17 +38,42 @@ func main() {
 		},
 	})
 
-	a.Window.NewWithOptions(application.WebviewWindowOptions{
+	// Start hidden and size the window once the screens are known, so it
+	// fits the display instead of being clamped by it.
+	w := a.Window.NewWithOptions(application.WebviewWindowOptions{
 		Title:            "mpckit",
-		Width:            1280,
-		Height:           860,
-		MinWidth:         760,
-		MinHeight:        600,
+		Width:            maxWidth,
+		Height:           maxHeight,
+		MinWidth:         minWidth,
+		MinHeight:        minHeight,
+		Hidden:           true,
 		BackgroundColour: application.NewRGB(0x15, 0x17, 0x1A),
 		URL:              "/",
 	})
 
+	a.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
+		if s := a.Screen.GetPrimary(); s != nil {
+			width, height := fitSize(s.WorkArea.Width, s.WorkArea.Height)
+			w.SetSize(width, height)
+		}
+		w.Center()
+		w.Show()
+	})
+
 	if err := a.Run(); err != nil {
 		log.Fatal(err)
 	}
 }
+
+// fitSize returns a 16:9 window size that takes screenShare of the work
+// area (areaW x areaH), capped at maxWidth x maxHeight and no smaller than
+// minWidth x minHeight.
+func fitSize(areaW, areaH int) (int, int) {
+	width := min(maxWidth, int(float64(areaW)*screenShare))
+	height := width * 9 / 16
+	if limit := int(float64(areaH) * screenShare); height > limit {
+		height = limit
+		width = height * 16 / 9
+	}
+	return max(width, minWidth), max(height, minHeight)
+}

+ 19 - 0
main_test.go

@@ -0,0 +1,19 @@
+package main
+
+import "testing"
+
+func TestFitSize(t *testing.T) {
+	cases := []struct{ areaW, areaH, w, h int }{
+		{1920, 1032, 1280, 720}, // 1080p at 100%: capped at the largest size
+		{1280, 688, 1100, 619},  // 1080p at 150% (taskbar removed): limited by height
+		{1536, 824, 1280, 720},  // 1080p at 125%
+		{2560, 1400, 1280, 720},
+		{1024, 600, 960, 540}, // too small: the minimum
+		{0, 0, 960, 540},
+	}
+	for _, c := range cases {
+		if w, h := fitSize(c.areaW, c.areaH); w != c.w || h != c.h {
+			t.Errorf("fitSize(%d, %d) = %d x %d, want %d x %d", c.areaW, c.areaH, w, h, c.w, c.h)
+		}
+	}
+}

+ 25 - 0
pull-requests/fit-window/fit-the-window-to-the-display.md

@@ -0,0 +1,25 @@
+# Fit the window to the display
+
+Branch: `fit-window` → `main`
+
+## What changed
+
+On bajdev's laptop (1920×1080 at about 150% scaling, so about 1280×690 in window units) the 1280×860 window was taller than the screen. It was clamped and the page scrolled. Also, the square pad cells made the grid grow with the window's width.
+
+- **Window size** (`main.go`): the window starts hidden. When the app has started, it is sized to 16:9 at 90% of the primary screen's work area, at most 1280×720, then centred and shown. The minimum size is 960×540 (also 16:9). On a 1280×688 work area that gives 1100×619. `fitSize` is unit-tested for 100%, 125% and 150% scaling and for small screens.
+- **Layout**:
+  - The page is exactly the window's height and never scrolls. The header, tabs and footer keep their height, and the two panels take the rest.
+  - The pad and sequence grids fill the panel height in four equal rows, instead of square cells sized by width. Long names are cut with an ellipsis.
+  - The title row and the project-name row are now one bar: mpckit, project name, the four buttons, About. This is a change from the mockup, to save about 70px of height.
+  - Buttons, inputs and tabs are 40px high (were 44–48). The bank buttons are 36px and the tuning sliders 28px. Semitones and Cents sit side by side.
+  - Below 1100px wide the visible "Project name" label is hidden; screen readers still get it. Below 600px tall the hint under each grid is hidden.
+  - The settings panel scrolls on its own if a window is shorter than the layout needs. It shouldn't at the default size.
+
+## Testing
+
+`go test ./...` passes, including the new `fitSize` test. `main.go` vets for Windows. The frontend still can't be built on the server (no Node). Please check on the laptop:
+
+1. `wails3 dev`: the window opens centred at about 1100×620 with no scrollbar, on both tabs.
+2. Make the window as small as it goes (960×540): still no page scrollbar, and the grid shows all 16 cells.
+3. Maximise: the layout fills the screen without a scrollbar.
+4. `npm run check` in `frontend` reports no errors.