| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703 |
- unit main;
- {$mode objfpc}{$H+}
- interface
- uses
- Classes, Sysutils, Fileutil, Forms, Controls, Graphics, Dialogs, ExtCtrls, LazFileUtils,
- Book, BookCollection, LCLIntf, LResources, StdCtrls, Menus, LCLType, IniFiles, unitSettingsDialog,
- unitCoverWorker, unitStorageXML, unitMetadata, UnitBookDialog, LazUTF8;
- type
- { TForm1 }
- TForm1 = class(TForm)
- EditSearch: Tedit;
- ComboSort: TComboBox;
- PopupBook: TPopupMenu;
- MenuEditBook: TMenuItem;
- ButtonSettings: Timage;
- ImageToolBar: Timage;
- ButtonAdd: Timage;
- Opendialog1: Topendialog;
- PanelBackground: Tscrollbox;
- procedure FormResize({%H-}Sender: TObject);
- procedure ButtonAddClick({%H-}Sender: TObject);
- procedure ButtonAddMouseEnter({%H-}Sender: TObject);
- procedure ButtonAddMouseLeave({%H-}Sender: TObject);
- procedure ButtonSettingsClick({%H-}Sender: TObject);
- procedure ButtonSettingsMouseEnter({%H-}Sender: TObject);
- procedure ButtonSettingsMouseLeave({%H-}Sender: TObject);
- procedure EditSearchEnter({%H-}Sender: TObject);
- procedure EditSearchExit({%H-}Sender: TObject);
- procedure EditSearchChange({%H-}Sender: TObject);
- procedure EditSearchKeyPress({%H-}Sender: TObject; var Key: Char);
- procedure ComboSortChange({%H-}Sender: TObject);
- procedure FormClose({%H-}Sender: TObject; var CloseAction: TCloseAction);
- procedure FormCreate({%H-}Sender: TObject);
- procedure FormKeyDown({%H-}Sender: TObject; var Key: Word; {%H-}Shift: TShiftState);
- procedure PanelBackgroundClick({%H-}Sender: TObject);
- procedure PanelBackgroundDragDrop({%H-}Sender, Source: TObject; X, Y: Integer);
- procedure PanelBackgroundDragOver({%H-}Sender, {%H-}Source: TObject; {%H-}X, {%H-}Y: Integer;
- {%H-}State: TDragState; var Accept: Boolean);
- procedure PanelBackgroundPaint({%H-}Sender: TObject);
- procedure RearrangeBooksOnScreen();
- procedure PanelBackgroundResize({%H-}Sender: TObject);
- function GetBookIndexAtPoint(X,Y:Integer):Integer;
- procedure UnselectAll;
- function GetCoverIndex(cover:TImage):Integer;
- procedure PopupEditBookClick({%H-}Sender: TObject);
- private
- mAdd,mAddHover,mGear,mGearHover:TPicture;
- LayoutTimer: TTimer;
- procedure LayoutTimerTick(Sender: TObject);
- procedure ApplyFilterAndLayout;
- function AppConfigPath: String;
- public
- { public declarations }
- end;
- var
- Form1: TForm1;
- bookList: TBookCollection;
- xSpace, ySpace: integer;
- dataXmlPath: String;
- booksDir: String;
- backgroundTile, toolbar: TPicture;
- coverWidth, coverHeight: Integer;
- optCopyBooks, optRenameBooks, optExtractMeta: Boolean;
- isClosing: Boolean = False;
- implementation
- {$R *.lfm}
- { Tform1 }
- procedure TForm1.FormResize({%H-}Sender: TObject);
- begin
- // debounce: restart the timer, don’t layout on every pixel move
- LayoutTimer.Enabled := False;
- LayoutTimer.Enabled := True;
- end;
- procedure TForm1.LayoutTimerTick(Sender: TObject);
- begin
- LayoutTimer.Enabled := False; // one-shot
- RearrangeBooksOnScreen;
- end;
- function TForm1.AppConfigPath: String;
- begin
- Result := IncludeTrailingPathDelimiter(GetAppConfigDirUTF8(False)) + 'config.ini';
- end;
- procedure TForm1.PanelBackgroundClick({%H-}Sender: TObject);
- begin
- ActiveControl:=PanelBackground;
- UnselectAll;
- PanelBackground.Invalidate;
- end;
- procedure TForm1.PanelBackgroundDragDrop({%H-}Sender, Source: TObject; X, Y: Integer);
- var src,dest:integer;
- begin
- src:=GetCoverIndex(TImage(Source));
- dest:=GetBookIndexAtPoint(X,Y);
- if (src > -1) and (dest > -1) then bookList.SwapBooks(src,dest);
- UnselectAll;
- // After changing book order, recalculate layout so covers move immediately
- RearrangeBooksOnScreen();
- end;
- procedure TForm1.PanelBackgroundDragOver({%H-}Sender, {%H-}Source: TObject; {%H-}X,
- {%H-}Y: Integer; {%H-}State: TDragState; var Accept: Boolean);
- begin
- Accept:=True;
- end;
- procedure TForm1.PanelBackgroundPaint({%H-}Sender: TObject);
- var w,h:Integer;
- x,y:Integer;
- begin
- // Safety: if no tile or invalid size, skip custom painting
- if (backgroundTile = nil) or (backgroundTile.Width <= 0) or (backgroundTile.Height <= 0) then
- Exit;
- x:=0;
- y:=0;
- w:=backgroundTile.Width;
- h:=backgroundTile.Height;
- while x < PanelBackground.Canvas.Width do
- begin
- while y < PanelBackground.Canvas.Height do
- begin
- PanelBackground.Canvas.Draw(x,y,backgroundTile.Graphic);
- y:=y+h;
- end;
- x:=x+w;
- y:=0;
- end;
- end;
- procedure TForm1.RearrangeBooksOnScreen;
- var
- visibleCovers: array of TImage;
- i, j, k, countVisible: Integer;
- availW, minGap, rowStart, rowCount: Integer;
- curY: Integer;
- x: Double;
- gap: Double;
- cover: TImage;
- function PanelClientWidth: Integer;
- begin
- // Use client width (exclude borders/scrollbar)
- Result := PanelBackground.ClientWidth;
- if Result <= 0 then Result := PanelBackground.Width;
- end;
- procedure CollectVisible;
- var i : Integer;
- begin
- SetLength(visibleCovers, 0);
- for i := 0 to bookList.Count - 1 do
- begin
- cover := bookList.Books[i].Cover;
- if Assigned(cover) and cover.Visible then
- begin
- SetLength(visibleCovers, Length(visibleCovers) + 1);
- visibleCovers[High(visibleCovers)] := cover;
- end;
- end;
- countVisible := Length(visibleCovers);
- end;
- // Can we fit N items with at least minGap spacing including left+right margins?
- function FitsWithMinGaps(n: Integer; width: Integer; gapPx: Integer): Boolean;
- var need: Integer;
- begin
- // total = n*bookWidth + (n+1)*gap (edge gaps included)
- need := (n * coverWidth) + ((n + 1) * gapPx);
- Result := need <= width;
- end;
- begin
- if (bookList = nil) or isClosing then Exit;
- PanelBackground.DisableAlign;
- try
- availW := PanelClientWidth;
- if availW <= 0 then Exit;
- minGap := xSpace; // your existing horizontal spacing as the minimum
- curY := ySpace; // top margin
- CollectVisible;
- // Early exit: nothing to place
- if countVisible = 0 then Exit;
- // Ensure covers have correct size (in case they were recreated)
- for i := 0 to countVisible - 1 do
- begin
- visibleCovers[i].Width := coverWidth;
- visibleCovers[i].Height := coverHeight;
- visibleCovers[i].Parent := PanelBackground;
- end;
- rowStart := 0;
- while rowStart < countVisible do
- begin
- // Determine how many items fit in this row with at least minGap gutters.
- rowCount := 1;
- while (rowStart + rowCount < countVisible)
- and FitsWithMinGaps(rowCount + 1, availW, minGap) do
- Inc(rowCount);
- // Compute the gap for this row:
- // - For full rows, distribute leftover width evenly across (rowCount+1) gaps.
- // - For the last row (rowStart+rowCount = countVisible), keep it left-aligned (minGap).
- if (rowStart + rowCount) < countVisible then
- begin
- // Full row → justified
- gap := (availW - (rowCount * coverWidth)) / (rowCount + 1);
- if gap < minGap then gap := minGap; // safety
- end
- else
- begin
- // Last row → left align
- gap := minGap; // safety
- end;
- // Place row items: start at left edge gap, then [cover + gap] repeated.
- x := gap;
- for j := 0 to rowCount - 1 do
- begin
- k := rowStart + j;
- cover := visibleCovers[k];
- cover.Left := Round(x);
- cover.Top := curY;
- x := x + coverWidth + gap;
- end;
- // Next row Y
- curY := curY + coverHeight + ySpace + 26;
- Inc(rowStart, rowCount);
- end;
- // Optional: ensure panel is tall enough; comment out if not needed.
- // PanelBackground.AutoSize := False;
- // PanelBackground.Height := curY + Yspace;
- finally
- PanelBackground.EnableAlign;
-
- PanelBackground.Invalidate;
- end;
- end;
- procedure TForm1.PanelBackgroundResize({%H-}Sender: TObject);
- begin
- if isClosing then Exit;
- RearrangeBooksOnScreen();
- EditSearch.Left := Width - EditSearch.Width - 20;
- if Assigned(ComboSort) then
- ComboSort.Left := EditSearch.Left - ComboSort.Width - 12;
- End;
- function TForm1.GetBookIndexAtPoint(X, Y: Integer): Integer;
- var i:Integer;
- cover:TImage;
- begin
- for i:=0 to bookList.Count-1 do
- begin
- cover:=bookList.Books[i].Cover;
- if Assigned(cover) and cover.Visible and
- (X >= cover.Left) and (X <= cover.Left + cover.Width) and
- (Y >= cover.Top) and (Y <= cover.Top + cover.Height) then
- begin
- result :=i;
- exit;
- end;
- end;
- result:=-1;
- end;
- procedure TForm1.UnselectAll;
- var i:Integer;
- begin
- for i:=0 to bookList.Count-1 do
- begin
- bookList.Books[i].isSelected:=False;
- end;
- end;
- function TForm1.GetCoverIndex(Cover: Timage): Integer;
- var i:integer;
- begin
- for i:=0 to bookList.count-1 do
- begin
- if Assigned(bookList.books[i].Cover) and (bookList.books[i].Cover = Cover) then
- begin
- result:=i;
- exit;
- end;
- end;
- result:=-1;
- end;
- procedure TForm1.PopupEditBookClick({%H-}Sender: TObject);
- var
- img: TImage;
- idx: Integer;
- dlg: TBookEditDialog;
- begin
- if (PopupBook = nil) or (PopupBook.PopupComponent = nil) then Exit;
- if not (PopupBook.PopupComponent is TImage) then Exit;
- img := TImage(PopupBook.PopupComponent);
- idx := GetCoverIndex(img);
- if idx < 0 then Exit;
- dlg := TBookEditDialog.Create(Self);
- try
- dlg.LoadBook(bookList.Books[idx]);
- if dlg.ShowModal = mrOK then
- begin
- // reflect changes visually
- bookList.Books[idx].EnsureScaledToCoverSize;
- ApplyFilterAndLayout;
- end;
- finally
- dlg.Free;
- end;
- end;
- procedure TForm1.FormClose({%H-}Sender: TObject; var CloseAction: TCloseAction);
- begin
- isClosing := True;
- if Assigned(LayoutTimer) then LayoutTimer.Enabled := False;
- // Ensure background worker thread is stopped before destroying books/controls
- CoverWorkerStop;
- try
- if Assigned(bookList) then
- SaveBooksXML(dataXmlPath, bookList);
- except
- // ignore save errors on shutdown
- end;
- // Free images created at runtime
- FreeAndNil(mAdd);
- FreeAndNil(mAddHover);
- FreeAndNil(mGear);
- FreeAndNil(mGearHover);
- FreeAndNil(backgroundTile);
- FreeAndNil(bookList);
- CloseAction := caFree;
- end;
- procedure TForm1.ButtonAddClick({%H-}Sender: TObject);
- var
- book : TBook;
- i : Integer;
- src : String;
- dest : String;
- fname,title,authors,ext : String;
- files: TStringList;
- function CleanName(const s:String):String;
- const bad = '/\?*:<>|"';
- var c:Char;
- begin
- Result := Trim(s);
- for c in bad do
- Result := StringReplace(Result, c, '_', [rfReplaceAll]);
- end;
- procedure ProcessFile(const AFile: String);
- begin
- src := AFile;
- dest := src;
- title := '';
- authors := '';
- if optExtractMeta then
- ExtractBookMetadata(src, title, authors);
- if optCopyBooks then
- begin
- ForceDirectories(booksDir);
- fname := ExtractFileName(src);
- if optRenameBooks and (title <> '') then
- begin
- ext := ExtractFileExt(src);
- fname := CleanName(title);
- if authors <> '' then
- fname := fname + ' - ' + CleanName(authors);
- fname := fname + ext;
- end;
- dest := IncludeTrailingPathDelimiter(booksDir) + fname;
- // Skip copy if source already resides in booksDir and avoid exceptions on failure
- if CompareFilenames(src, dest) <> 0 then
- begin
- try
- CopyFile(src, dest);
- except
- // fall back to original path if copy fails for any reason
- dest := src;
- end;
- end;
- end;
- book:=TBook.Create(PanelBackground);
- book.FilePath:= dest;
- if optExtractMeta then
- begin
- if title <> '' then book.Title := title
- else book.Title := ChangeFileExt(ExtractFileName(dest), '');
- if authors <> '' then book.Authors := authors;
- end
- else
- book.Title := ChangeFileExt(ExtractFileName(dest), '');
- bookList.AddBook(book);
- book.Cover.Width:=coverWidth;
- book.Cover.Height:=coverHeight;
- book.Cover.Parent:=PanelBackground;
- book.Cover.PopupMenu := PopupBook;
- // Ensure the pre-scaled image matches the final cover size
- book.EnsureScaledToCoverSize;
- CoverWorkerEnqueueBookIfMissing(book);
- end;
- begin
- if OpenDialog1.Execute then
- begin
- files := TStringList.Create;
- try
- if OpenDialog1.Files.Count > 0 then
- files.Assign(OpenDialog1.Files)
- else if OpenDialog1.FileName <> '' then
- files.Add(OpenDialog1.FileName);
- for i := 0 to files.Count - 1 do
- ProcessFile(files[i]);
- CoverWorkerStart;
- RearrangeBooksOnScreen();
- finally
- files.Free;
- end;
- end;
- End;
- procedure TForm1.ButtonAddMouseEnter({%H-}Sender: TObject);
- begin
- ButtonAdd.Picture := mAddHover;
- end;
- procedure TForm1.ButtonAddMouseLeave({%H-}Sender: TObject);
- begin
- ButtonAdd.Picture := mAdd;
- end;
- procedure TForm1.ButtonSettingsClick({%H-}Sender: TObject);
- begin
- SettingsDialog := TSettingsDialog.Create(Self);
- try
- SettingsDialog.ShowModal;
- finally
- SettingsDialog.Free;
- end;
- end;
- procedure TForm1.ButtonSettingsMouseEnter({%H-}Sender: TObject);
- begin
- ButtonSettings.Picture := mGearHover;
- end;
- procedure TForm1.ButtonSettingsMouseLeave({%H-}Sender: TObject);
- begin
- ButtonSettings.Picture := mGear;
- end;
- procedure TForm1.EditSearchEnter({%H-}Sender: TObject);
- begin
- // Only clear placeholder, keep any typed text intact
- if EditSearch.Text = 'Search...' then
- EditSearch.Text := '';
- end;
- procedure TForm1.EditSearchExit({%H-}Sender: TObject);
- begin
- // Restore placeholder only if empty; do not wipe actual queries
- if Trim(EditSearch.Text) = '' then
- EditSearch.Text := 'Search...';
- end;
- procedure TForm1.EditSearchKeyPress({%H-}Sender: TObject; var Key: Char);
- begin
- if Key = #13 then
- begin
- //perform the search here
- end;
- end;
- procedure TForm1.FormCreate({%H-}Sender: TObject);
- var
- i:integer;
- cfgDir, cfgPath, dataDir: String;
- ini: TIniFile;
- autoPdfCover: Boolean;
- begin
- coverWidth:=130;
- coverHeight:=200;
- xSpace:=40;
- ySpace:=25;
- Form1.KeyPreview:=True;
- ActiveControl:=PanelBackground;
- backgroundTile:=TPicture.Create;
- backgroundTile.LoadFromLazarusResource('shelf');
- PanelBackground.DoubleBuffered := True; // reduce flicker
- Self.OnResize := @FormResize;
- LayoutTimer := TTimer.Create(Self);
- LayoutTimer.Enabled := False;
- LayoutTimer.Interval := 60; // ~60ms debounce feels snappy
- LayoutTimer.OnTimer := @LayoutTimerTick;
-
- mAdd:=TPicture.Create;
- mAddHover:=Tpicture.Create;
- mGear:=Tpicture.Create;
- mGearHover:=Tpicture.Create;
- mAdd.LoadFromLazarusResource('add');
- mAddHover.LoadFromLazarusResource('add_hover');
- mGear.LoadFromLazarusResource('gear');
- mGearHover.LoadFromLazarusResource('gear_hover');
- ButtonAdd.Picture:=mAdd;
- ButtonSettings.Picture:=mGear;
- // Load toolbar image from Lazarus resources instead of large LFM-embedded data
- try
- ImageToolBar.Picture.LoadFromLazarusResource('toolbar');
- except
- // ignore if resource missing; fallback to LFM-embedded picture
- end;
- // Load config.ini if present to resolve paths and options
- cfgDir := IncludeTrailingPathDelimiter(GetAppConfigDirUTF8(False));
- if not DirectoryExistsUTF8(cfgDir) then CreateDirUTF8(cfgDir);
- cfgPath := cfgDir + 'config.ini';
- ini := TIniFile.Create(cfgPath);
- try
- dataDir := ini.ReadString('general', 'data_dir', cfgDir);
- booksDir := ini.ReadString('general', 'books_dir', cfgDir);
- optCopyBooks := ini.ReadBool('general', 'copy_books', True);
- optRenameBooks := ini.ReadBool('general', 'rename_books', True);
- optExtractMeta := ini.ReadBool('general', 'extract_metadata', True);
- autoPdfCover := ini.ReadBool('general','auto_pdf_cover', True);
- finally
- ini.Free;
- end;
- if not DirectoryExistsUTF8(dataDir) then CreateDirUTF8(dataDir);
- if not DirectoryExistsUTF8(booksDir) then CreateDirUTF8(booksDir);
- dataXmlPath := IncludeTrailingPathDelimiter(dataDir) + 'books.xml';
- bookList:=TBookCollection.Create;
- // speed up startup: we skipped synchronous PDF generation during load
- SetPdfCoverGenerationEnabled(False);
- try
- if FileExistsUTF8(dataXmlPath) then
- LoadBooksXML(dataXmlPath, bookList, PanelBackground);
- finally
- SetPdfCoverGenerationEnabled(autoPdfCover); // re-enable per settings
- end;
- for i:=0 to bookList.Count-1 do
- begin
- with bookList.Books[i] do
- begin
- Cover.Width:=coverWidth;
- Cover.Height:=coverHeight;
- Cover.Parent:=PanelBackground;
- Cover.PopupMenu := PopupBook;
- EnsureScaledToCoverSize;
- end;
- end;
- RearrangeBooksOnScreen();
- // Restore sort selection and apply
- try
- ini := TIniFile.Create(AppConfigPath);
- try
- ComboSort.ItemIndex := ini.ReadInteger('ui','sort_by', 0);
- finally
- ini.Free;
- end;
- ComboSortChange(nil);
- except
- // ignore
- end;
- // Background: generate covers only where still generic
- CoverWorkerEnqueueMissingFromBookList(bookList);
- CoverWorkerStart;
- end;
- procedure TForm1.FormKeyDown({%H-}Sender: TObject; var Key: Word; {%H-}Shift: TShiftState);
- var i:Integer;
- b:TBook;
- begin
- if Key = VK_DELETE then
- begin
- for i:= bookList.Count-1 downto 0 do
- begin
- if bookList.Books[i].isSelected = True then
- begin
- // Remove the cover control first (owned by PanelBackground), then free book
- b := bookList.Books[i];
- // Ensure the background worker won't touch this book anymore
- CoverWorkerRemoveBook(b);
- if Assigned(b.Cover) then b.Cover.Free;
- bookList.Remove(b);
- b.Free;
- end;
- end;
- RearrangeBooksOnScreen();
- end;
- end;
- procedure TForm1.EditSearchChange({%H-}Sender: TObject);
- begin
- ApplyFilterAndLayout;
- end;
- procedure TForm1.ComboSortChange({%H-}Sender: TObject);
- begin
- case ComboSort.ItemIndex of
- 0: ; // Recently Added (keep current order)
- 1: bookList.SortByTitle;
- 2: bookList.SortByAuthor;
- end;
- // Persist selection
- try
- with TIniFile.Create(AppConfigPath) do
- try
- WriteInteger('ui', 'sort_by', ComboSort.ItemIndex);
- finally
- Free;
- end;
- except
- end;
- ApplyFilterAndLayout;
- end;
- procedure TForm1.ApplyFilterAndLayout;
- var
- q, lt, la: String;
- i: Integer;
- b: TBook;
- showIt: Boolean;
- begin
- if (bookList = nil) then Exit;
- q := UTF8LowerCase(Trim(EditSearch.Text));
- if (q = '') or (q = 'search...') then
- begin
- // show all
- for i := 0 to bookList.Count - 1 do
- if Assigned(bookList.Books[i].Cover) then
- bookList.Books[i].Cover.Visible := True;
- end
- else
- begin
- for i := 0 to bookList.Count - 1 do
- begin
- b := bookList.Books[i];
- lt := UTF8LowerCase(b.Title);
- la := UTF8LowerCase(b.Authors);
- showIt := (Pos(q, lt) > 0) or (Pos(q, la) > 0);
- if Assigned(b.Cover) then b.Cover.Visible := showIt;
- end;
- end;
- RearrangeBooksOnScreen();
- end;
- initialization
- {$i mybookshelf.lrs}
- end.
|